From 5e53757d43d0508466a210ffee2141beafbc3a5d Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:30:08 +0530 Subject: [PATCH 01/38] Update app.js --- app.js | 88 +++++++++++++++++++++------------------------------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/app.js b/app.js index 5ab128e4b4..66de816cab 100644 --- a/app.js +++ b/app.js @@ -1,61 +1,37 @@ -const express = require("express"); +// Import Express.js +const express = require('express'); + +// Create an Express app const app = express(); -const port = process.env.PORT || 3001; -app.get("/", (req, res) => res.type('html').send(html)); +// Middleware to parse JSON bodies +app.use(express.json()); + +// Set port and verify_token +const port = process.env.PORT || 3000; +const verifyToken = process.env.VERIFY_TOKEN; + +// Route for GET requests +app.get('/', (req, res) => { + const { 'hub.mode': mode, 'hub.challenge': challenge, 'hub.verify_token': token } = req.query; -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); + if (mode === 'subscribe' && token === verifyToken) { + console.log('WEBHOOK VERIFIED'); + res.status(200).send(challenge); + } else { + res.status(403).end(); + } +}); -server.keepAliveTimeout = 120 * 1000; -server.headersTimeout = 120 * 1000; +// Route for POST requests +app.post('/', (req, res) => { + const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + console.log(`\n\nWebhook received ${timestamp}\n`); + console.log(JSON.stringify(req.body, null, 2)); + res.status(200).end(); +}); -const html = ` - - - - Hello from Render! - - - - - -
- Hello from Render! -
- - -` +// Start the server +app.listen(port, () => { + console.log(`\nListening on port ${port}\n`); +}); From fdd95724d9d36cc50039d3c50dd2f0881c16c7d6 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Fri, 26 Jun 2026 19:10:59 +0530 Subject: [PATCH 02/38] Add discount image auto-reply for WhatsApp messages When an incoming WhatsApp message contains "update the discount", the webhook automatically replies to the sender with the discount image via the WhatsApp Cloud API. Phone number ID and token are read from env vars. Co-Authored-By: Claude Sonnet 4.6 --- app.js | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- render.yaml | 6 ++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 66de816cab..356f1ddf80 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,36 @@ app.use(express.json()); // Set port and verify_token const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; +const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; +const whatsappToken = process.env.WHATSAPP_TOKEN; + +const DISCOUNT_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/diwali-offer2-updated'; + +async function sendImageMessage(to) { + const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; + const body = { + messaging_product: 'whatsapp', + to, + type: 'image', + image: { link: DISCOUNT_IMAGE_URL }, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${whatsappToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`WhatsApp API error ${response.status}: ${text}`); + } + + return response.json(); +} // Route for GET requests app.get('/', (req, res) => { @@ -24,11 +54,29 @@ app.get('/', (req, res) => { }); // Route for POST requests -app.post('/', (req, res) => { +app.post('/', async (req, res) => { const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); console.log(`\n\nWebhook received ${timestamp}\n`); console.log(JSON.stringify(req.body, null, 2)); + + // Acknowledge immediately so WhatsApp doesn't retry res.status(200).end(); + + try { + const messages = req.body?.entry?.[0]?.changes?.[0]?.value?.messages; + if (!messages?.length) return; + + for (const message of messages) { + const text = message?.text?.body ?? ''; + if (text.toLowerCase().includes('update the discount')) { + console.log(`Sending discount image to ${message.from}`); + await sendImageMessage(message.from); + console.log(`Discount image sent to ${message.from}`); + } + } + } catch (err) { + console.error('Error handling message:', err.message); + } }); // Start the server diff --git a/render.yaml b/render.yaml index 9583d53df2..5a4e5ac55d 100644 --- a/render.yaml +++ b/render.yaml @@ -8,3 +8,9 @@ services: envVars: - key: NODE_ENV value: production + - key: VERIFY_TOKEN + sync: false + - key: WHATSAPP_PHONE_NUMBER_ID + sync: false + - key: WHATSAPP_TOKEN + sync: false From 4a26ed66c7fb06a023069c5718f6fab58415d1bb Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:20:33 +0530 Subject: [PATCH 03/38] feat: replace static discount auto-reply with GPT tool-calling assistant (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GPT decision loop (with per-phone conversation history) that routes inbound WhatsApp messages to campaign-graphic actions — listing graphics, checking allowed edits, editing via Adobe Express, and bulk generation from an uploaded file — instead of only replying to a hardcoded "update the discount" phrase. Co-authored-by: Priyank Modi --- app.js | 218 ++++++++++-- package-lock.json | 881 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- yarn.lock | 249 +++++++------ 4 files changed, 1196 insertions(+), 155 deletions(-) create mode 100644 package-lock.json diff --git a/app.js b/app.js index 356f1ddf80..0eb789d47d 100644 --- a/app.js +++ b/app.js @@ -1,29 +1,34 @@ -// Import Express.js const express = require('express'); +const OpenAI = require('openai'); -// Create an Express app const app = express(); - -// Middleware to parse JSON bodies app.use(express.json()); -// Set port and verify_token const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; const whatsappToken = process.env.WHATSAPP_TOKEN; -const DISCOUNT_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/diwali-offer2-updated'; +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); -async function sendImageMessage(to) { - const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; - const body = { - messaging_product: 'whatsapp', - to, - type: 'image', - image: { link: DISCOUNT_IMAGE_URL }, - }; +// In-memory conversation history per phone number (last 20 messages kept) +const conversationHistory = new Map(); + +function getHistory(phoneNumber) { + return conversationHistory.get(phoneNumber) || []; +} + +function appendHistory(phoneNumber, role, content) { + const history = conversationHistory.get(phoneNumber) || []; + history.push({ role, content }); + if (history.length > 20) history.shift(); + conversationHistory.set(phoneNumber, history); +} + +// ── WhatsApp helpers ───────────────────────────────────────────────────────── +async function whatsappPost(body) { + const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; const response = await fetch(url, { method: 'POST', headers: { @@ -32,19 +37,140 @@ async function sendImageMessage(to) { }, body: JSON.stringify(body), }); - if (!response.ok) { const text = await response.text(); throw new Error(`WhatsApp API error ${response.status}: ${text}`); } - return response.json(); } -// Route for GET requests +function sendText(to, text) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); +} + +function sendImage(to, link) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); +} + +// ── GPT tool definitions ───────────────────────────────────────────────────── + +const tools = [ + { + type: 'function', + function: { + name: 'list_campaign_graphics', + description: 'List all graphics available in the current campaign', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'ask_for_more_information', + description: 'Ask the user a clarifying question when the request is ambiguous or incomplete', + parameters: { + type: 'object', + properties: { + question: { type: 'string', description: 'The clarifying question to send to the user' }, + }, + required: ['question'], + }, + }, + }, + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: 'Check what edits are permitted on the current graphic', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'edit_graphic', + description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + parameters: { + type: 'object', + properties: { + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['edits'], + }, + }, + }, + { + type: 'function', + function: { + name: 'generate_bulk_graphics', + description: 'Generate multiple graphics from an uploaded CSV or Excel file', + parameters: { + type: 'object', + properties: { + filename: { type: 'string', description: 'Name of the uploaded CSV or Excel file' }, + }, + }, + }, + }, +]; + +// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits() { + // TODO: fetch from Adobe Express API + return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; +} + +async function actionEditGraphic(edits) { + // TODO: call Adobe Express API + const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); + return `Graphic updated successfully:\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +// ── GPT decision engine ────────────────────────────────────────────────────── + +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information.`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages, + tools, + tool_choice: 'required', + }); + + return response.choices[0].message; +} + +// ── Webhook routes ─────────────────────────────────────────────────────────── + app.get('/', (req, res) => { const { 'hub.mode': mode, 'hub.challenge': challenge, 'hub.verify_token': token } = req.query; - if (mode === 'subscribe' && token === verifyToken) { console.log('WEBHOOK VERIFIED'); res.status(200).send(challenge); @@ -53,13 +179,11 @@ app.get('/', (req, res) => { } }); -// Route for POST requests app.post('/', async (req, res) => { const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); console.log(`\n\nWebhook received ${timestamp}\n`); console.log(JSON.stringify(req.body, null, 2)); - // Acknowledge immediately so WhatsApp doesn't retry res.status(200).end(); try { @@ -67,19 +191,61 @@ app.post('/', async (req, res) => { if (!messages?.length) return; for (const message of messages) { - const text = message?.text?.body ?? ''; - if (text.toLowerCase().includes('update the discount')) { - console.log(`Sending discount image to ${message.from}`); - await sendImageMessage(message.from); - console.log(`Discount image sent to ${message.from}`); + const userText = message?.text?.body; + if (!userText) continue; + + const phoneNumber = message.from; + console.log(`Message from ${phoneNumber}: ${userText}`); + + appendHistory(phoneNumber, 'user', userText); + + const gptMessage = await decideAction(phoneNumber, userText); + const toolCall = gptMessage.tool_calls?.[0]; + if (!toolCall) continue; + + const action = toolCall.function.name; + const args = JSON.parse(toolCall.function.arguments || '{}'); + console.log(`GPT chose action: ${action}`, args); + + let replyText; + + switch (action) { + case 'list_campaign_graphics': + await sendText(phoneNumber, '⏳ Fetching campaign graphics...'); + replyText = await actionListCampaignGraphics(); + break; + + case 'ask_for_more_information': + replyText = args.question; + break; + + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(args.edits); + break; + + case 'generate_bulk_graphics': + await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); + replyText = await actionGenerateBulkGraphics(args.filename); + break; + + default: + replyText = "Sorry, I couldn't figure out how to handle that request."; } + + await sendText(phoneNumber, replyText); + appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { console.error('Error handling message:', err.message); } }); -// Start the server app.listen(port, () => { console.log(`\nListening on port ${port}\n`); }); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..05cf00a448 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,881 @@ +{ + "name": "express-hello-world", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "express-hello-world", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "express": "^5.0.0", + "openai": "^6.45.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" + }, + "node_modules/body-parser": { + "version": "2.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz", + "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "3.1.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.5.2", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "^3.0.0", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.40.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "2.1.24", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dependencies": { + "mime-db": "1.40.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.3.6", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz", + "integrity": "sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", + "http-errors": "2.0.0", + "merge-descriptors": "^2.0.0", + "methods": "~1.1.2", + "mime-types": "^3.0.0", + "on-finished": "2.4.1", + "once": "1.4.0", + "parseurl": "~1.3.3", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "router": "^2.0.0", + "safe-buffer": "5.2.1", + "send": "^1.1.0", + "serve-static": "^2.1.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "^2.0.0", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/finalhandler": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.5.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.45.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz", + "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz", + "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", + "dependencies": { + "array-flatten": "3.0.0", + "is-promise": "4.0.0", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "^8.0.0", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "1.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", + "dependencies": { + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "2.1.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/package.json b/package.json index 43954fe280..567801e154 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "node app.js" }, "dependencies": { - "express": "^5.0.0" + "express": "^5.0.0", + "openai": "^6.45.0" } } diff --git a/yarn.lock b/yarn.lock index 35c2bede4d..f4940dc9e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,7 +4,7 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" @@ -12,12 +12,12 @@ accepts@^2.0.0: array-flatten@3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz" integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== body-parser@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz" integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== dependencies: bytes "3.1.2" @@ -34,12 +34,12 @@ body-parser@^2.0.1: bytes@3.1.2: version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== call-bind@^1.0.7: version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== dependencies: es-define-property "^1.0.0" @@ -50,62 +50,50 @@ call-bind@^1.0.7: content-disposition@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz" integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== dependencies: safe-buffer "5.2.1" -content-type@^1.0.5, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - cookie-signature@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz" integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== cookie@0.6.0: version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +debug@^4.3.5, debug@4.3.6: + version "4.3.6" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@2.6.9: version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" debug@3.1.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== dependencies: ms "2.0.0" -debug@4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - -debug@^4.3.5: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - define-data-property@^1.1.4: version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz" integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== dependencies: es-define-property "^1.0.0" @@ -114,54 +102,54 @@ define-data-property@^1.1.4: depd@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@1.2.0, destroy@^1.2.0: +destroy@^1.2.0, destroy@1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== ee-first@1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== encodeurl@~1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== es-define-property@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz" integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== dependencies: get-intrinsic "^1.2.4" es-errors@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== etag@^1.8.1, etag@~1.8.1: version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: version "5.0.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz" integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== dependencies: accepts "^2.0.0" @@ -199,7 +187,7 @@ express@^5.0.0: finalhandler@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz" integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== dependencies: debug "2.6.9" @@ -212,27 +200,27 @@ finalhandler@^2.0.0: forwarded@0.2.0: version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - fresh@^0.5.2: version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@2.0.0: + version "2.0.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + function-bind@^1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== dependencies: es-errors "^1.3.0" @@ -243,38 +231,38 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: gopd@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz" integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== dependencies: get-intrinsic "^1.1.3" has-property-descriptors@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== dependencies: es-define-property "^1.0.0" has-proto@^1.0.1: version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== has-symbols@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== hasown@^2.0.0: version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== dependencies: function-bind "^1.1.2" -http-errors@2.0.0, http-errors@^2.0.0: +http-errors@^2.0.0, http-errors@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz" integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== dependencies: depd "2.0.0" @@ -285,141 +273,146 @@ http-errors@2.0.0, http-errors@^2.0.0: iconv-lite@0.5.2: version "0.5.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz" integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== dependencies: safer-buffer ">= 2.1.2 < 3" iconv-lite@0.6.3: version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" inherits@2.0.4: version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ipaddr.js@1.9.1: version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== is-promise@4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - media-typer@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== +media-typer@0.3.0: + version "0.3.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + merge-descriptors@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== methods@~1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@^1.53.0: + version "1.53.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== mime-db@1.40.0: version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz" integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== mime-db@1.52.0: version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-db@^1.53.0: - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - mime-types@^2.1.35: version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" mime-types@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz" integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== dependencies: mime-db "^1.53.0" mime-types@~2.1.24: version "2.1.24" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz" integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== dependencies: mime-db "1.40.0" +ms@^2.1.3: + version "2.1.3" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + ms@2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - negotiator@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== object-inspect@^1.13.1: version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz" integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== -on-finished@2.4.1, on-finished@^2.4.1: +on-finished@^2.4.1, on-finished@2.4.1: version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" once@1.4.0: version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" +openai@^6.45.0: + version "6.45.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz" + integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== + parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== path-to-regexp@^8.0.0: version "8.1.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz" integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== proxy-addr@~2.0.7: version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== dependencies: forwarded "0.2.0" @@ -427,19 +420,19 @@ proxy-addr@~2.0.7: qs@6.13.0: version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== dependencies: side-channel "^1.0.6" range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== raw-body@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz" integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== dependencies: bytes "3.1.2" @@ -449,7 +442,7 @@ raw-body@^3.0.0: router@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz" integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== dependencies: array-flatten "3.0.0" @@ -462,17 +455,17 @@ router@^2.0.0: safe-buffer@5.2.1: version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== send@^1.0.0, send@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz" integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== dependencies: debug "^4.3.5" @@ -490,7 +483,7 @@ send@^1.0.0, send@^1.1.0: serve-static@^2.1.0: version "2.1.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz" integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== dependencies: encodeurl "^2.0.0" @@ -500,7 +493,7 @@ serve-static@^2.1.0: set-function-length@^1.2.1: version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== dependencies: define-data-property "^1.1.4" @@ -512,12 +505,12 @@ set-function-length@^1.2.1: setprototypeof@1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== side-channel@^1.0.6: version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== dependencies: call-bind "^1.0.7" @@ -525,19 +518,19 @@ side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -statuses@2.0.1, statuses@^2.0.1: +statuses@^2.0.1, statuses@2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== toidentifier@1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== type-is@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz" integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== dependencies: content-type "^1.0.5" @@ -546,28 +539,28 @@ type-is@^2.0.0: type-is@~1.6.18: version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== dependencies: media-typer "0.3.0" mime-types "~2.1.24" -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== utils-merge@1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== vary@~1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From b1ed8d852219a647563fe745d217545717439205 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:45:36 +0530 Subject: [PATCH 04/38] fix: regenerate lockfiles against the public npm registry (#2) Co-authored-by: Priyank Modi --- yarn.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index f4940dc9e4..50df762342 100644 --- a/yarn.lock +++ b/yarn.lock @@ -113,7 +113,7 @@ destroy@^1.2.0, destroy@1.2.0: ee-first@1.1.1: version "1.1.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" @@ -123,7 +123,7 @@ encodeurl@^2.0.0, encodeurl@~2.0.0: encodeurl@~1.0.2: version "1.0.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== es-define-property@^1.0.0: version "1.0.0" @@ -140,12 +140,12 @@ es-errors@^1.3.0: escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: version "5.0.0" @@ -308,7 +308,7 @@ media-typer@^1.1.0: media-typer@0.3.0: version "0.3.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== merge-descriptors@^2.0.0: version "2.0.0" @@ -318,7 +318,7 @@ merge-descriptors@^2.0.0: methods@~1.1.2: version "1.1.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== mime-db@^1.53.0: version "1.53.0" @@ -364,7 +364,7 @@ ms@^2.1.3: ms@2.0.0: version "2.0.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" @@ -548,17 +548,17 @@ type-is@~1.6.18: unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== utils-merge@1.0.1: version "1.0.1" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== vary@~1.1.2: version "1.1.2" resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" From f5acca1d365388b0a862f96ada46b46916529991 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:55:42 +0530 Subject: [PATCH 05/38] fix: regenerate yarn.lock against public npm registry (#3) yarn.lock resolved every package from Adobe's internal Artifactory (picked up via local ~/.npmrc), which Render's build servers can't reach or validate certs for, causing "unable to get local issuer certificate" during deploy. Regenerated against registry.npmjs.org and dropped package-lock.json since render.yaml only runs yarn. Co-authored-by: Priyank Modi --- package-lock.json | 881 ---------------------------------------------- yarn.lock | 671 ++++++++++++++++------------------- 2 files changed, 297 insertions(+), 1255 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 05cf00a448..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,881 +0,0 @@ -{ - "name": "express-hello-world", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "express-hello-world", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "express": "^5.0.0", - "openai": "^6.45.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz", - "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" - }, - "node_modules/body-parser": { - "version": "2.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz", - "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "3.1.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.5.2", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "^3.0.0", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/mime-db": { - "version": "1.40.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/mime-types": { - "version": "2.1.24", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz", - "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", - "dependencies": { - "mime-db": "1.40.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/body-parser/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz", - "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz", - "integrity": "sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A==", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.0.1", - "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", - "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", - "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", - "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/finalhandler": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz", - "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", - "dependencies": { - "mime-db": "^1.53.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/openai": { - "version": "6.45.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", - "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@smithy/hash-node": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz", - "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", - "engines": { - "node": ">=16" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz", - "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", - "dependencies": { - "array-flatten": "3.0.0", - "is-promise": "4.0.0", - "methods": "~1.1.2", - "parseurl": "~1.3.3", - "path-to-regexp": "^8.0.0", - "setprototypeof": "1.2.0", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "1.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", - "dependencies": { - "debug": "^4.3.5", - "destroy": "^1.2.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^0.5.2", - "http-errors": "^2.0.0", - "mime-types": "^2.1.35", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - } - } -} diff --git a/yarn.lock b/yarn.lock index 50df762342..e4212e3db7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,563 +4,486 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/accepts/-/accepts-2.0.0.tgz" + resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" negotiator "^1.0.0" -array-flatten@3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/array-flatten/-/array-flatten-3.0.0.tgz" - integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== - -body-parser@^2.0.1: - version "2.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/body-parser/-/body-parser-2.0.1.tgz" - integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== +body-parser@^2.2.1: + version "2.3.0" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "3.1.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.5.2" - on-finished "2.4.1" - qs "6.13.0" - raw-body "^3.0.0" - type-is "~1.6.18" - unpipe "1.0.0" - -bytes@3.1.2: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bytes/-/bytes-3.1.2.tgz" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.7: - version "1.0.7" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/call-bind/-/call-bind-1.0.7.tgz" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - es-define-property "^1.0.0" es-errors "^1.3.0" function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-disposition/-/content-disposition-1.0.0.tgz" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== dependencies: - safe-buffer "5.2.1" + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" -content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: version "1.0.5" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/content-type/-/content-type-1.0.5.tgz" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie-signature/-/cookie-signature-1.2.1.tgz" - integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== - -cookie@0.6.0: - version "0.6.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/cookie/-/cookie-0.6.0.tgz" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -debug@^4.3.5, debug@4.3.6: - version "4.3.6" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-4.3.6.tgz" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" +content-type@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" + integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== -debug@2.6.9: - version "2.6.9" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -debug@3.1.0: - version "3.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/define-data-property/-/define-data-property-1.1.4.tgz" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== +debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" + ms "^2.1.3" -depd@2.0.0: +depd@^2.0.0, depd@~2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/depd/-/depd-2.0.0.tgz" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@^1.2.0, destroy@1.2.0: - version "1.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/destroy/-/destroy-1.2.0.tgz" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" ee-first@1.1.1: version "1.1.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ee-first/-/ee-first-1.1.1.tgz" + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -encodeurl@^2.0.0, encodeurl@~2.0.0: +encodeurl@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-2.0.0.tgz" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/encodeurl/-/encodeurl-1.0.2.tgz" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-define-property/-/es-define-property-1.0.0.tgz" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== es-errors@^1.3.0: version "1.3.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/es-errors/-/es-errors-1.3.0.tgz" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -escape-html@^1.0.3, escape-html@~1.0.3: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +escape-html@^1.0.3: version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/escape-html/-/escape-html-1.0.3.tgz" + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== -etag@^1.8.1, etag@~1.8.1: +etag@^1.8.1: version "1.8.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/etag/-/etag-1.8.1.tgz" + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: - version "5.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/express/-/express-5.0.0.tgz" - integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== + version "5.2.1" + resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== dependencies: accepts "^2.0.0" - body-parser "^2.0.1" + body-parser "^2.2.1" content-disposition "^1.0.0" - content-type "~1.0.4" - cookie "0.6.0" + content-type "^1.0.5" + cookie "^0.7.1" cookie-signature "^1.2.1" - debug "4.3.6" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "^2.0.0" - fresh "2.0.0" - http-errors "2.0.0" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" merge-descriptors "^2.0.0" - methods "~1.1.2" mime-types "^3.0.0" - on-finished "2.4.1" - once "1.4.0" - parseurl "~1.3.3" - proxy-addr "~2.0.7" - qs "6.13.0" - range-parser "~1.2.1" - router "^2.0.0" - safe-buffer "5.2.1" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" send "^1.1.0" - serve-static "^2.1.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "^2.0.0" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/finalhandler/-/finalhandler-2.0.0.tgz" - integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" forwarded@0.2.0: version "0.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/forwarded/-/forwarded-0.2.0.tgz" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@^0.5.2: - version "0.5.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-0.5.2.tgz" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fresh@2.0.0: +fresh@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/fresh/-/fresh-2.0.0.tgz" + resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== function-bind@^1.1.2: version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/function-bind/-/function-bind-1.1.2.tgz" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" es-errors "^1.3.0" + es-object-atoms "^1.1.1" function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" -gopd@^1.0.1: +get-proto@^1.0.1: version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/gopd/-/gopd-1.0.1.tgz" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - get-intrinsic "^1.1.3" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.0.1: - version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-proto/-/has-proto-1.0.3.tgz" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -has-symbols@^1.0.3: - version "1.0.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/has-symbols/-/has-symbols-1.0.3.tgz" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -hasown@^2.0.0: - version "2.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/hasown/-/hasown-2.0.2.tgz" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== dependencies: function-bind "^1.1.2" -http-errors@^2.0.0, http-errors@2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -iconv-lite@0.5.2: - version "0.5.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.5.2.tgz" - integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== dependencies: - safer-buffer ">= 2.1.2 < 3" + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -inherits@2.0.4: +inherits@~2.0.4: version "2.0.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/inherits/-/inherits-2.0.4.tgz" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ipaddr.js@1.9.1: version "1.9.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ipaddr.js/-/ipaddr.js-1.9.1.tgz" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-promise@4.0.0: +is-promise@^4.0.0: version "4.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/is-promise/-/is-promise-4.0.0.tgz" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@^1.1.0: version "1.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-1.1.0.tgz" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== -media-typer@0.3.0: - version "0.3.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/media-typer/-/media-typer-0.3.0.tgz" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - merge-descriptors@^2.0.0: version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/merge-descriptors/-/merge-descriptors-2.0.0.tgz" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== -methods@~1.1.2: - version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/methods/-/methods-1.1.2.tgz" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -mime-db@^1.53.0: - version "1.53.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.53.0.tgz" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-db@1.40.0: - version "1.40.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.40.0.tgz" - integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-db/-/mime-db-1.52.0.tgz" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.35: - version "2.1.35" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.35.tgz" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime-types@^3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-3.0.0.tgz" - integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== - dependencies: - mime-db "^1.53.0" +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@~2.1.24: - version "2.1.24" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/mime-types/-/mime-types-2.1.24.tgz" - integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== dependencies: - mime-db "1.40.0" + mime-db "^1.54.0" ms@^2.1.3: version "2.1.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.3.tgz" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -ms@2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.0.0.tgz" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/ms/-/ms-2.1.2.tgz" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - negotiator@^1.0.0: version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/negotiator/-/negotiator-1.0.0.tgz" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/object-inspect/-/object-inspect-1.13.2.tgz" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== -on-finished@^2.4.1, on-finished@2.4.1: +on-finished@^2.4.1: version "2.4.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/on-finished/-/on-finished-2.4.1.tgz" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" -once@1.4.0: +once@^1.4.0: version "1.4.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/once/-/once-1.4.0.tgz" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" openai@^6.45.0: version "6.45.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/openai/-/openai-6.45.0.tgz" + resolved "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz#53840c5c5848884dfbff47006f839b27d1d955b9" integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== -parseurl@^1.3.3, parseurl@~1.3.3: +parseurl@^1.3.3: version "1.3.3" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/parseurl/-/parseurl-1.3.3.tgz" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== path-to-regexp@^8.0.0: - version "8.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/path-to-regexp/-/path-to-regexp-8.1.0.tgz" - integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== + version "8.4.2" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== -proxy-addr@~2.0.7: +proxy-addr@^2.0.7: version "2.0.7" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/proxy-addr/-/proxy-addr-2.0.7.tgz" + resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== dependencies: forwarded "0.2.0" ipaddr.js "1.9.1" -qs@6.13.0: - version "6.13.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/qs/-/qs-6.13.0.tgz" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== +qs@^6.14.0, qs@^6.15.2: + version "6.15.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== dependencies: - side-channel "^1.0.6" + es-define-property "^1.0.1" + side-channel "^1.1.1" -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/range-parser/-/range-parser-1.2.1.tgz" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== -raw-body@^3.0.0: - version "3.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/raw-body/-/raw-body-3.0.0.tgz" - integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== +raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.6.3" - unpipe "1.0.0" + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" -router@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/router/-/router-2.0.0.tgz" - integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== +router@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== dependencies: - array-flatten "3.0.0" - is-promise "4.0.0" - methods "~1.1.2" - parseurl "~1.3.3" + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" path-to-regexp "^8.0.0" - setprototypeof "1.2.0" - utils-merge "1.0.1" -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/safer-buffer/-/safer-buffer-2.1.2.tgz" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -send@^1.0.0, send@^1.1.0: - version "1.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/send/-/send-1.1.0.tgz" - integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== dependencies: - debug "^4.3.5" - destroy "^1.2.0" + debug "^4.4.3" encodeurl "^2.0.0" escape-html "^1.0.3" etag "^1.8.1" - fresh "^0.5.2" - http-errors "^2.0.0" - mime-types "^2.1.35" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" ms "^2.1.3" on-finished "^2.4.1" range-parser "^1.2.1" - statuses "^2.0.1" + statuses "^2.0.2" -serve-static@^2.1.0: - version "2.1.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/serve-static/-/serve-static-2.1.0.tgz" - integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== dependencies: encodeurl "^2.0.0" escape-html "^1.0.3" parseurl "^1.3.3" - send "^1.0.0" + send "^1.2.0" -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/set-function-length/-/set-function-length-1.2.2.tgz" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== dependencies: - define-data-property "^1.1.4" es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" + object-inspect "^1.13.4" -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/setprototypeof/-/setprototypeof-1.2.0.tgz" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" -side-channel@^1.0.6: - version "1.0.6" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/side-channel/-/side-channel-1.0.6.tgz" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.2" es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" -statuses@^2.0.1, statuses@2.0.1: - version "2.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -toidentifier@1.0.1: +toidentifier@~1.0.1: version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/toidentifier/-/toidentifier-1.0.1.tgz" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -type-is@^2.0.0: - version "2.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-2.0.0.tgz" - integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== dependencies: - content-type "^1.0.5" + content-type "^2.0.0" media-typer "^1.1.0" mime-types "^3.0.0" -type-is@~1.6.18: - version "1.6.18" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/type-is/-/type-is-1.6.18.tgz" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@~1.0.0: version "1.0.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/unpipe/-/unpipe-1.0.0.tgz" + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -utils-merge@1.0.1: - version "1.0.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/utils-merge/-/utils-merge-1.0.1.tgz" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -vary@~1.1.2: +vary@^1.1.2: version "1.1.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/vary/-/vary-1.1.2.tgz" + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/wrappy/-/wrappy-1.0.2.tgz" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From 4fc86418f5f904e42fad9f96c75b338422ff67de Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Mon, 6 Jul 2026 19:05:52 +0530 Subject: [PATCH 06/38] fix: declare OPENAI_API_KEY in render.yaml envVars app.js reads process.env.OPENAI_API_KEY but the Blueprint never declared the key, so a value set in the Render dashboard was never wired into the service, causing "Missing credentials" at boot. --- render.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/render.yaml b/render.yaml index 5a4e5ac55d..f4b937f914 100644 --- a/render.yaml +++ b/render.yaml @@ -14,3 +14,5 @@ services: sync: false - key: WHATSAPP_TOKEN sync: false + - key: OPENAI_API_KEY + sync: false From f7ee32e1a6371b6fe2806236b7c6bde00eb21608 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Mon, 6 Jul 2026 20:37:24 +0530 Subject: [PATCH 07/38] fix: make OpenAI model/baseURL configurable and log request/error details Model was hardcoded to gpt-4o while the configured Azure OpenAI resource only has a gpt-5.4-mini deployment, causing every request to fail with a 404 "deployment does not exist" error. Reads OPENAI_MODEL/OPENAI_BASE_URL from env instead, and logs the resolved config plus full error details on failure to make future mismatches easy to diagnose. Co-Authored-By: Claude Sonnet 5 --- app.js | 24 +++++++++++++++++++++--- render.yaml | 4 ++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 0eb789d47d..3f5ad89e3f 100644 --- a/app.js +++ b/app.js @@ -8,8 +8,10 @@ const port = process.env.PORT || 3000; const verifyToken = process.env.VERIFY_TOKEN; const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; const whatsappToken = process.env.WHATSAPP_TOKEN; +const openaiBaseURL = process.env.OPENAI_BASE_URL || undefined; +const openaiModel = process.env.OPENAI_MODEL || 'gpt-4o'; -const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: openaiBaseURL }); // In-memory conversation history per phone number (last 20 messages kept) const conversationHistory = new Map(); @@ -157,8 +159,12 @@ If the request is ambiguous or missing details, use ask_for_more_information.`, { role: 'user', content: userMessage }, ]; + console.log( + `[decideAction] calling chat.completions.create — model: ${openaiModel}, baseURL: ${openai.baseURL}, phone: ${phoneNumber}` + ); + const response = await openai.chat.completions.create({ - model: 'gpt-4o', + model: openaiModel, messages, tools, tool_choice: 'required', @@ -242,10 +248,22 @@ app.post('/', async (req, res) => { appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { - console.error('Error handling message:', err.message); + console.error('Error handling message:', { + message: err.message, + status: err.status, + code: err.code, + type: err.type, + requestID: err.requestID, + error: err.error, + model: openaiModel, + baseURL: openai.baseURL, + }); } }); app.listen(port, () => { console.log(`\nListening on port ${port}\n`); + console.log(`[startup] OPENAI_BASE_URL: ${process.env.OPENAI_BASE_URL ?? '(unset, defaults to api.openai.com)'}`); + console.log(`[startup] OPENAI_MODEL: ${openaiModel}`); + console.log(`[startup] OPENAI_API_KEY set: ${Boolean(process.env.OPENAI_API_KEY)}`); }); diff --git a/render.yaml b/render.yaml index f4b937f914..55fe1154ed 100644 --- a/render.yaml +++ b/render.yaml @@ -16,3 +16,7 @@ services: sync: false - key: OPENAI_API_KEY sync: false + - key: OPENAI_BASE_URL + sync: false + - key: OPENAI_MODEL + sync: false From 272e14ae2fb920eb771d26c0641be77bb72f267f Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:51:28 +0530 Subject: [PATCH 08/38] Feat/express edit flow (#4) * docs: add design spec for mocked Adobe Express image-edit flow * docs: add implementation plan for image-edit flow * feat: add in-memory tracked-image store * feat: add mock Adobe Express API module * feat: add mock Meta media upload module * feat: extract GPT action handlers and add image-edit validation flow * feat: wire image-edit flow into the webhook and GPT tool schema * feat: map Croma earbuds graphic to Price/Address/Product Image/Partner Logo edits Replaces the New Arrival Poster mock seed image with the real Croma earbuds graphic and its allowed-edit fields. * feat: return fixed updated Croma earbuds image for any edit Any allowed edit on the Croma earbuds graphic now resolves to the fixed croma1-earbuds-updated image, sent as a new WhatsApp message, instead of a randomized mock render/upload URL. --------- Co-authored-by: Priyank Modi --- actions.js | 61 ++ actions.test.js | 66 ++ app.js | 58 +- .../plans/2026-07-13-express-edit-flow.md | 705 ++++++++++++++++++ .../2026-07-13-express-edit-flow-design.md | 132 ++++ expressApi.js | 33 + expressApi.test.js | 39 + imageStore.js | 28 + imageStore.test.js | 35 + metaUpload.js | 10 + metaUpload.test.js | 19 + package.json | 3 +- 12 files changed, 1158 insertions(+), 31 deletions(-) create mode 100644 actions.js create mode 100644 actions.test.js create mode 100644 docs/superpowers/plans/2026-07-13-express-edit-flow.md create mode 100644 docs/superpowers/specs/2026-07-13-express-edit-flow-design.md create mode 100644 expressApi.js create mode 100644 expressApi.test.js create mode 100644 imageStore.js create mode 100644 imageStore.test.js create mode 100644 metaUpload.js create mode 100644 metaUpload.test.js diff --git a/actions.js b/actions.js new file mode 100644 index 0000000000..a990e7b913 --- /dev/null +++ b/actions.js @@ -0,0 +1,61 @@ +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); +const { uploadImageToMeta } = require('./metaUpload'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + const { unlockedLayers } = getTemplateInfo(image.templateId); + const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `Edits allowed on "${image.name}":\n${layerList}`; +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + const { unlockedLayers } = getTemplateInfo(image.templateId); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); + + if (disallowedKeys.length > 0) { + const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; + } + + const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); + image.currentEdits = mergedEdits; + + const uploadedUrl = await uploadImageToMeta(renderedImageUrl); + await sendImage(phoneNumber, uploadedUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; diff --git a/actions.test.js b/actions.test.js new file mode 100644 index 0000000000..fa88280a39 --- /dev/null +++ b/actions.test.js @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { getTrackedImages } = require('./imageStore'); + +test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + assert.match(reply, /Diwali Offer Banner/); + assert.match(reply, /discount_text/); +}); + +test('actionCheckAllowedEdits lists Price, Address, Product Image, Partner Logo for the Croma earbuds image', async () => { + const reply = await actionCheckAllowedEdits('phone-croma', 'img_3'); + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /- Price/); + assert.match(reply, /- Address/); + assert.match(reply, /- Product Image/); + assert.match(reply, /- Partner Logo/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { + let sendImageCalled = false; + const sendImage = async () => { + sendImageCalled = true; + }; + + const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic sends the fixed updated Croma earbuds image for any allowed edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-5', 'img_3', { Price: '999' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].link, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); + +test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); + + assert.match(reply, /Updated "Summer Sale Flyer"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-4'); + assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); + + const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); + assert.equal(image.currentEdits.headline, 'Flash Sale'); +}); diff --git a/app.js b/app.js index 3f5ad89e3f..1d703d50e1 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,12 @@ const express = require('express'); const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); const app = express(); app.use(express.json()); @@ -83,24 +90,33 @@ const tools = [ type: 'function', function: { name: 'check_allowed_edits', - description: 'Check what edits are permitted on the current graphic', - parameters: { type: 'object', properties: {} }, + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, }, }, { type: 'function', function: { name: 'edit_graphic', - description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', parameters: { type: 'object', properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, edits: { type: 'object', description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', }, }, - required: ['edits'], + required: ['image_id', 'edits'], }, }, }, @@ -119,33 +135,12 @@ const tools = [ }, ]; -// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── - -async function actionListCampaignGraphics() { - // TODO: fetch from campaign API - return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; -} - -async function actionCheckAllowedEdits() { - // TODO: fetch from Adobe Express API - return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; -} - -async function actionEditGraphic(edits) { - // TODO: call Adobe Express API - const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); - return `Graphic updated successfully:\n${summary}`; -} - -async function actionGenerateBulkGraphics(filename) { - // TODO: parse CSV/Excel and call Adobe Express API per row - return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; -} - // ── GPT decision engine ────────────────────────────────────────────────────── async function decideAction(phoneNumber, userMessage) { const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); const messages = [ { @@ -153,7 +148,10 @@ async function decideAction(phoneNumber, userMessage) { content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. -If the request is ambiguous or missing details, use ask_for_more_information.`, +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, }, ...last3, { role: 'user', content: userMessage }, @@ -227,12 +225,12 @@ app.post('/', async (req, res) => { case 'check_allowed_edits': await sendText(phoneNumber, '⏳ Checking allowed edits...'); - replyText = await actionCheckAllowedEdits(); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); break; case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); - replyText = await actionEditGraphic(args.edits); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); break; case 'generate_bulk_graphics': diff --git a/docs/superpowers/plans/2026-07-13-express-edit-flow.md b/docs/superpowers/plans/2026-07-13-express-edit-flow.md new file mode 100644 index 0000000000..b36d079cae --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-express-edit-flow.md @@ -0,0 +1,705 @@ +# Express Image Edit Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a WhatsApp user ask what edits are allowed on a previously-sent graphic, request an edit, have it validated against the graphic's unlocked layers, applied via a mocked Adobe Express API, uploaded via a mocked Meta upload, and re-sent as an updated image — all with placeholder Express/Meta calls that can be swapped for real ones later. + +**Architecture:** Three new small, dependency-free modules (`imageStore.js` for per-phone-number tracked-image state, `expressApi.js` for mock template/edit calls, `metaUpload.js` for the mock media upload) get unit tests via Node's built-in test runner. A fourth new module, `actions.js`, extracts all GPT tool-call action handlers (including the two existing ones, `list_campaign_graphics` and `generate_bulk_graphics`, moved unchanged) so the new `edit_graphic` logic can be unit-tested with a fake `sendImage` — no real WhatsApp/OpenAI credentials needed for tests. `app.js` is left as the thin orchestrator: WhatsApp API calls, GPT tool schemas/system prompt, and the webhook route wiring args into `actions.js`. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` (no new dependencies). + +## Global Constraints + +- No real Adobe Express or Meta API calls — all such calls are placeholder/mock functions returning mock values (per spec, these get swapped for real implementations later). +- Actually sending the initial graphic image is out of scope — tracked images are pre-seeded in memory at first access per phone number (spec: 3 fixed seed entries: Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster). +- State is in-memory only, no persistence across restarts (matches existing `conversationHistory` pattern in app.js). +- No new npm dependencies — use Node's built-in `node:test` test runner. +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. + +--- + +## Task 1: `imageStore.js` — tracked-image state + +**Files:** +- Create: `imageStore.js` +- Test: `imageStore.test.js` +- Modify: `package.json` (add a `test` script) + +**Interfaces:** +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id: string, name: string, templateId: string, currentEdits: object }>` (seeds 3 fixed entries on first call for a phone number, returns the same array reference on later calls). +- Produces: `findTrackedImage(phoneNumber, imageId) -> object | undefined`. + +- [ ] **Step 1: Write the failing test** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTrackedImages, findTrackedImage } = require('./imageStore'); + +test('getTrackedImages seeds 3 images on first access', () => { + const images = getTrackedImages('111'); + assert.equal(images.length, 3); + assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); + assert.deepEqual(images[0].currentEdits, {}); +}); + +test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { + const first = getTrackedImages('222'); + first[0].currentEdits.headline = 'Flash Sale'; + const second = getTrackedImages('222'); + assert.equal(second[0].currentEdits.headline, 'Flash Sale'); +}); + +test('getTrackedImages seeds independently per phone number', () => { + getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; + const other = getTrackedImages('444'); + assert.deepEqual(other[0].currentEdits, {}); +}); + +test('findTrackedImage returns the matching image', () => { + getTrackedImages('555'); + const image = findTrackedImage('555', 'img_2'); + assert.equal(image.name, 'Summer Sale Flyer'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + getTrackedImages('666'); + const image = findTrackedImage('666', 'img_999'); + assert.equal(image, undefined); +}); +``` + +- [ ] **Step 2: Add the test script and run to verify failure** + +Modify `package.json` scripts section to: + +```json +"scripts": { + "start": "node app.js", + "test": "node --test" +}, +``` + +Run: `node --test imageStore.test.js` +Expected: FAIL — `Cannot find module './imageStore'` + +- [ ] **Step 3: Write the implementation** + +Create `imageStore.js`: + +```js +const SEED_IMAGES = [ + { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, + { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, + { id: 'img_3', name: 'New Arrival Poster', templateId: 'tpl_newarrival' }, +]; + +const trackedImages = new Map(); + +function getTrackedImages(phoneNumber) { + if (!trackedImages.has(phoneNumber)) { + trackedImages.set( + phoneNumber, + SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) + ); + } + return trackedImages.get(phoneNumber); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +module.exports = { getTrackedImages, findTrackedImage }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test imageStore.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add imageStore.js imageStore.test.js package.json +git commit -m "feat: add in-memory tracked-image store" +``` + +--- + +## Task 2: `expressApi.js` — mock Adobe Express calls + +**Files:** +- Create: `expressApi.js` +- Test: `expressApi.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `getTemplateInfo(templateId) -> { templateId: string, unlockedLayers: string[] }`. +- Produces: `applyEdit(templateId, currentEdits, newEdits) -> { mergedEdits: object, renderedImageUrl: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); + +test('getTemplateInfo returns the unlocked layers for a known template', () => { + const info = getTemplateInfo('tpl_diwali'); + assert.deepEqual(info, { + templateId: 'tpl_diwali', + unlockedLayers: ['discount_text', 'headline', 'background_color'], + }); +}); + +test('getTemplateInfo returns an empty layer list for an unknown template', () => { + const info = getTemplateInfo('tpl_does_not_exist'); + assert.deepEqual(info.unlockedLayers, []); +}); + +test('applyEdit merges new edits on top of current edits', () => { + const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); + assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); +}); + +test('applyEdit returns a rendered image url that references the template', () => { + const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); + assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test expressApi.test.js` +Expected: FAIL — `Cannot find module './expressApi'` + +- [ ] **Step 3: Write the implementation** + +Create `expressApi.js`: + +```js +const TEMPLATE_LAYERS = { + tpl_diwali: ['discount_text', 'headline', 'background_color'], + tpl_summer: ['headline', 'font_color'], + tpl_newarrival: ['headline'], +}; + +function getTemplateInfo(templateId) { + return { + templateId, + unlockedLayers: TEMPLATE_LAYERS[templateId] || [], + }; +} + +let renderRevision = 0; + +function applyEdit(templateId, currentEdits, newEdits) { + const mergedEdits = { ...currentEdits, ...newEdits }; + renderRevision += 1; + return { + mergedEdits, + renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, + }; +} + +module.exports = { getTemplateInfo, applyEdit }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test expressApi.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: add mock Adobe Express API module" +``` + +--- + +## Task 3: `metaUpload.js` — mock Meta media upload + +**Files:** +- Create: `metaUpload.js` +- Test: `metaUpload.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `uploadImageToMeta(renderedImageUrl) -> Promise` (mock CDN URL). + +- [ ] **Step 1: Write the failing test** + +Create `metaUpload.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test metaUpload.test.js` +Expected: FAIL — `Cannot find module './metaUpload'` + +- [ ] **Step 3: Write the implementation** + +Create `metaUpload.js`: + +```js +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test metaUpload.test.js` +Expected: PASS — 2 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add metaUpload.js metaUpload.test.js +git commit -m "feat: add mock Meta media upload module" +``` + +--- + +## Task 4: `actions.js` — extract and extend GPT tool-call action handlers + +**Files:** +- Create: `actions.js` +- Test: `actions.test.js` + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage` from `./imageStore` (Task 1); `getTemplateInfo`, `applyEdit` from `./expressApi` (Task 2); `uploadImageToMeta` from `./metaUpload` (Task 3). +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged behavior, moved from app.js). +- Produces: `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`. +- Produces: `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise` — `sendImage` is injected `(to, link) => Promise` so tests don't need real WhatsApp calls. +- Produces: `actionGenerateBulkGraphics(filename) -> Promise` (unchanged behavior, moved from app.js). + +- [ ] **Step 1: Write the failing test** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { getTrackedImages } = require('./imageStore'); + +test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + assert.match(reply, /Diwali Offer Banner/); + assert.match(reply, /discount_text/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { + let sendImageCalled = false; + const sendImage = async () => { + sendImageCalled = true; + }; + + const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); + + assert.match(reply, /Updated "Summer Sale Flyer"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-4'); + assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); + + const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); + assert.equal(image.currentEdits.headline, 'Flash Sale'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test actions.test.js` +Expected: FAIL — `Cannot find module './actions'` + +- [ ] **Step 3: Write the implementation** + +Create `actions.js`: + +```js +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); +const { uploadImageToMeta } = require('./metaUpload'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + const { unlockedLayers } = getTemplateInfo(image.templateId); + const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `Edits allowed on "${image.name}":\n${layerList}`; +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + const { unlockedLayers } = getTemplateInfo(image.templateId); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); + + if (disallowedKeys.length > 0) { + const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; + } + + const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); + image.currentEdits = mergedEdits; + + const uploadedUrl = await uploadImageToMeta(renderedImageUrl); + await sendImage(phoneNumber, uploadedUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test actions.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `imageStore.test.js`, `expressApi.test.js`, `metaUpload.test.js`, `actions.test.js` (15 tests total) + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: extract GPT action handlers and add image-edit validation flow" +``` + +--- + +## Task 5: Wire `app.js` to the new modules + +**Files:** +- Modify: `app.js:1-2` (imports) +- Modify: `app.js:59-120` (tool definitions) +- Modify: `app.js:122-143` (delete — action handlers now live in `actions.js`) +- Modify: `app.js:147-174` (`decideAction` system prompt) +- Modify: `app.js:218-245` (webhook switch statement) + +**Interfaces:** +- Consumes: `getTrackedImages` from `./imageStore` (Task 1); `actionListCampaignGraphics`, `actionCheckAllowedEdits`, `actionEditGraphic`, `actionGenerateBulkGraphics` from `./actions` (Task 4). +- Produces: nothing new — this task is pure wiring, verified manually (no unit test; this is the orchestration layer that touches the real WhatsApp/OpenAI SDKs already excluded from automated testing per the spec). + +- [ ] **Step 1: Update imports** + +At the top of `app.js`, replace: + +```js +const express = require('express'); +const OpenAI = require('openai'); +``` + +with: + +```js +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +- [ ] **Step 2: Add `image_id` to the `check_allowed_edits` and `edit_graphic` tool definitions** + +In the `tools` array, replace the `check_allowed_edits` entry: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: 'Check what edits are permitted on the current graphic', + parameters: { type: 'object', properties: {} }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +And replace the `edit_graphic` entry: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + parameters: { + type: 'object', + properties: { + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['edits'], + }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['image_id', 'edits'], + }, + }, + }, +``` + +- [ ] **Step 3: Delete the old inline action handlers** + +Delete this entire block (now provided by `actions.js`): + +```js +// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits() { + // TODO: fetch from Adobe Express API + return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; +} + +async function actionEditGraphic(edits) { + // TODO: call Adobe Express API + const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); + return `Graphic updated successfully:\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} +``` + +- [ ] **Step 4: Add the tracked-images list to the `decideAction` system prompt** + +Replace: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information.`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +with: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +- [ ] **Step 5: Pass `phoneNumber`/`image_id`/`sendImage` through the webhook switch** + +Replace: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(args.edits); + break; +``` + +with: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; +``` + +- [ ] **Step 6: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 7: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 8: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all 15 tests passing + +- [ ] **Step 9: Commit** + +```bash +git add app.js +git commit -m "feat: wire image-edit flow into the webhook and GPT tool schema" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** template/layer lookup (Task 2), allowed-edit validation (Task 4 `actionEditGraphic`), Express edit + Meta upload + re-send (Task 4 + Task 5 wiring), image reference inferred by GPT from a system-prompt-provided list (Task 5 Step 4), pre-seeded mock images since real sending is out of scope (Task 1) — all covered. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from the existing code (out of scope for this feature) and are intentional, not gaps in this plan. +- **Type consistency:** `sendImage` signature `(to, link) => Promise` is consistent between `actions.js`'s `actionEditGraphic` (Task 4) and its call site in `app.js` (Task 5 Step 5), which passes the existing `sendImage(to, link)` from `app.js`. `image.id`/`image.name`/`image.templateId`/`image.currentEdits` field names are consistent across `imageStore.js`, `expressApi.js` consumers, and `actions.js`. diff --git a/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md new file mode 100644 index 0000000000..272d63626f --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md @@ -0,0 +1,132 @@ +# Design: Image edit flow (Adobe Express, mocked) + +## Goal + +Support this WhatsApp conversation flow: + +1. User has previously been sent a message containing an image (graphic). +2. User asks what edits are allowed on one of those images. +3. System looks up which template the image used and which layers are unlocked (via Adobe Express API). +4. User asks for a specific edit. +5. System checks whether the requested edit is one of the allowed edits. +6. System calls the Adobe Express API to apply the edit. +7. System uploads the resulting image to Meta and gets back a URL. +8. System sends a new WhatsApp message with the updated image. + +Adobe Express API calls are out of scope for this iteration — all Express/Meta-upload calls are placeholder functions returning mock values, structured so they can be swapped for real implementations later without changing the surrounding flow. + +Actually sending the *initial* graphic image (step 1) is out of scope. Instead, a small set of mock "sent images" is pre-seeded at server startup for any phone number, so the rest of the flow has something to operate against. + +## Data model + +New in-memory store, `imageStore.js`, following the same per-phone-number Map pattern as the existing `conversationHistory`: + +```js +// Map> +// Seeded lazily on first access for a phone number — same 3 mock entries every time. +{ + id: 'img_1', // stable id GPT references in tool calls + name: 'Diwali Offer Banner', // human-readable, shown to GPT so it can match user text + templateId: 'tpl_diwali', // Express template id + currentEdits: {}, // accumulates applied edits across calls +} +``` + +Three seed entries (mirroring the existing `list_campaign_graphics` mock names): Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster, each with a distinct mock `templateId`. + +`getTrackedImages(phoneNumber)` returns (seeding if needed) the array; `findTrackedImage(phoneNumber, imageId)` returns one entry or `undefined`. + +## GPT tool changes + +GPT is responsible for figuring out *which* tracked image a user's message refers to (per your choice of "GPT infers from text"), so the system prompt must include the phone number's tracked images (id + name) on every call to `decideAction`. Both relevant tools gain an `image_id` parameter: + +```js +check_allowed_edits({ image_id }) +edit_graphic({ image_id, edits }) +``` + +`image_id` is required on both. Tool descriptions instruct GPT to pick the id from the list provided in the system prompt, matching the user's description (e.g. "the Diwali one") to the closest tracked image name. + +System prompt gains a section like: + +``` +Images previously sent to this user (reference by id): +- img_1: Diwali Offer Banner +- img_2: Summer Sale Flyer +- img_3: New Arrival Poster +``` + +## Mock Adobe Express API (`expressApi.js`) + +Placeholder module, no network calls: + +```js +function getTemplateInfo(templateId) { + // Mock: returns different unlocked layers per template so behavior isn't uniform. + // e.g. tpl_diwali -> ['discount_text', 'headline', 'background_color'] + // tpl_summer -> ['headline', 'font_color'] + // tpl_newarrival -> ['headline'] +} + +function applyEdit(templateId, currentEdits, newEdits) { + // Mock: merges edits, returns a fake rendered-image reference + // e.g. { renderedImageUrl: 'https://mock-express.local/render/tpl_diwali?rev=3' } +} +``` + +Both are synchronous or trivially async (`Promise.resolve`) mocks — no real HTTP calls yet. + +## Mock Meta upload + +Added alongside the existing WhatsApp helpers in app.js (or a small `whatsapp.js` if we split further — not required for this scope): + +```js +async function uploadImageToMeta(renderedImageUrl) { + // Mock: pretend to upload to Meta's media endpoint, return a fake CDN url + // e.g. `https://mock-meta-cdn.local/media/.png` +} +``` + +`sendImage(to, link)` already exists and takes a link — the mock URL from `uploadImageToMeta` is passed straight into it. + +## Action handlers + +### `actionCheckAllowedEdits(phoneNumber, imageId)` + +1. `findTrackedImage(phoneNumber, imageId)` — if not found, return an error message ("I couldn't find that image — here's what I have: ..."). +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Return a formatted list of allowed edits for that specific image (not the generic hardcoded string currently returned). + +### `actionEditGraphic(phoneNumber, imageId, edits)` + +1. `findTrackedImage(phoneNumber, imageId)` — same not-found handling as above. +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Validate: every key in `edits` must be in `unlockedLayers`. If any key isn't allowed, return a message naming the rejected field(s) and listing the actually-allowed fields — **do not call Express or Meta**. This is the "system checks if this is one of the allowed edits" step, done in code, not by GPT. +4. `applyEdit(templateId, image.currentEdits, edits)` → mock render; merge `edits` into `image.currentEdits`. +5. `uploadImageToMeta(renderedImageUrl)` → mock URL. +6. `sendImage(phoneNumber, mockUrl)` — new WhatsApp message with the updated image. +7. Return a short confirming text (e.g. "Updated: discount_text → 70%") which is sent as a follow-up text message via the existing `sendText` call in the webhook handler. + +## Webhook wiring + +In `app.post('/')`, the `check_allowed_edits` and `edit_graphic` cases pass `args.image_id` (and `args.edits`) through to the updated action handlers. No change to the outer loop structure. + +## Error handling + +- Unknown `image_id` (GPT hallucination): friendly text response listing currently tracked images, no crash. +- Disallowed edit field(s): friendly text response naming what's not allowed and what is, no Express/Meta calls made. +- Mock functions never throw — real error handling (network failures, Express API errors) is deferred to when real API calls replace the mocks. + +## Testing + +Manual verification only for this mocked iteration (no live Express/Meta credentials exist yet): +- Simulate incoming webhook payloads for: "what edits can I make to the summer flyer", followed by "change the headline to Flash Sale" — confirm the reply lists the right mock unlocked layers and then confirms the edit + attempts a mock image send. +- Simulate an edit request for a field not in `unlockedLayers` — confirm it's rejected with the allowed-fields message and no image is sent. +- Simulate a reference to a nonexistent image — confirm the not-found message. + +## Out of scope + +- Real Adobe Express API integration. +- Real Meta media upload. +- Actually sending the initial graphic image that seeds the flow (images are pre-seeded in memory instead). +- Persistence across server restarts (in-memory only, matches existing `conversationHistory` pattern). diff --git a/expressApi.js b/expressApi.js new file mode 100644 index 0000000000..0a352c054d --- /dev/null +++ b/expressApi.js @@ -0,0 +1,33 @@ +const TEMPLATE_LAYERS = { + tpl_diwali: ['discount_text', 'headline', 'background_color'], + tpl_summer: ['headline', 'font_color'], + tpl_croma_earbuds: ['Price', 'Address', 'Product Image', 'Partner Logo'], +}; + +function getTemplateInfo(templateId) { + return { + templateId, + unlockedLayers: TEMPLATE_LAYERS[templateId] || [], + }; +} + +let renderRevision = 0; + +function applyEdit(templateId, currentEdits, newEdits) { + const mergedEdits = { ...currentEdits, ...newEdits }; + + if (templateId === 'tpl_croma_earbuds') { + return { + mergedEdits, + renderedImageUrl: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated', + }; + } + + renderRevision += 1; + return { + mergedEdits, + renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, + }; +} + +module.exports = { getTemplateInfo, applyEdit }; diff --git a/expressApi.test.js b/expressApi.test.js new file mode 100644 index 0000000000..c9b3126535 --- /dev/null +++ b/expressApi.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); + +test('getTemplateInfo returns the unlocked layers for a known template', () => { + const info = getTemplateInfo('tpl_diwali'); + assert.deepEqual(info, { + templateId: 'tpl_diwali', + unlockedLayers: ['discount_text', 'headline', 'background_color'], + }); +}); + +test('getTemplateInfo returns the unlocked layers for the Croma earbuds template', () => { + const info = getTemplateInfo('tpl_croma_earbuds'); + assert.deepEqual(info, { + templateId: 'tpl_croma_earbuds', + unlockedLayers: ['Price', 'Address', 'Product Image', 'Partner Logo'], + }); +}); + +test('getTemplateInfo returns an empty layer list for an unknown template', () => { + const info = getTemplateInfo('tpl_does_not_exist'); + assert.deepEqual(info.unlockedLayers, []); +}); + +test('applyEdit merges new edits on top of current edits', () => { + const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); + assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); +}); + +test('applyEdit returns a rendered image url that references the template', () => { + const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); + assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); +}); + +test('applyEdit returns the fixed updated Croma earbuds image for any edit', () => { + const result = applyEdit('tpl_croma_earbuds', {}, { Price: '999' }); + assert.equal(result.renderedImageUrl, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); diff --git a/imageStore.js b/imageStore.js new file mode 100644 index 0000000000..136d365bc8 --- /dev/null +++ b/imageStore.js @@ -0,0 +1,28 @@ +const SEED_IMAGES = [ + { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, + { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, + { + id: 'img_3', + name: 'Croma Earbuds', + templateId: 'tpl_croma_earbuds', + url: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds', + }, +]; + +const trackedImages = new Map(); + +function getTrackedImages(phoneNumber) { + if (!trackedImages.has(phoneNumber)) { + trackedImages.set( + phoneNumber, + SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) + ); + } + return trackedImages.get(phoneNumber); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +module.exports = { getTrackedImages, findTrackedImage }; diff --git a/imageStore.test.js b/imageStore.test.js new file mode 100644 index 0000000000..841acf9d78 --- /dev/null +++ b/imageStore.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTrackedImages, findTrackedImage } = require('./imageStore'); + +test('getTrackedImages seeds 3 images on first access', () => { + const images = getTrackedImages('111'); + assert.equal(images.length, 3); + assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); + assert.deepEqual(images[0].currentEdits, {}); +}); + +test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { + const first = getTrackedImages('222'); + first[0].currentEdits.headline = 'Flash Sale'; + const second = getTrackedImages('222'); + assert.equal(second[0].currentEdits.headline, 'Flash Sale'); +}); + +test('getTrackedImages seeds independently per phone number', () => { + getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; + const other = getTrackedImages('444'); + assert.deepEqual(other[0].currentEdits, {}); +}); + +test('findTrackedImage returns the matching image', () => { + getTrackedImages('555'); + const image = findTrackedImage('555', 'img_2'); + assert.equal(image.name, 'Summer Sale Flyer'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + getTrackedImages('666'); + const image = findTrackedImage('666', 'img_999'); + assert.equal(image, undefined); +}); diff --git a/metaUpload.js b/metaUpload.js new file mode 100644 index 0000000000..4a6a68402b --- /dev/null +++ b/metaUpload.js @@ -0,0 +1,10 @@ +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + if (!renderedImageUrl.startsWith('https://mock-express.local/')) { + return renderedImageUrl; + } + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; diff --git a/metaUpload.test.js b/metaUpload.test.js new file mode 100644 index 0000000000..e4aed1b9ca --- /dev/null +++ b/metaUpload.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); + +test('uploadImageToMeta passes through urls that are already publicly hosted', async () => { + const url = await uploadImageToMeta('https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); + assert.equal(url, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); diff --git a/package.json b/package.json index 567801e154..854ad81ab5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "license": "MIT", "private": false, "scripts": { - "start": "node app.js" + "start": "node app.js", + "test": "node --test" }, "dependencies": { "express": "^5.0.0", From 8bef9c07816687ab773855c7ad9bd1a930df2859 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:50:04 +0530 Subject: [PATCH 09/38] Feat/real express api integration (#5) * Add design spec for global fixed edit-flow responses Whenever an edit is requested, always send the fixed Croma earbuds image; whenever allowed-edits is asked, always reply with the fixed layer list. Removes per-template edit-key validation. Co-Authored-By: Claude Sonnet 5 * Add design spec for real Adobe Express API integration Replaces the mocked expressApi.js/imageStore.js/metaUpload.js flow with real tagged-documents/generate-variation/status calls against a shared docID catalog, per the brainstorming session. * Ignore .superpowers scratch directory Co-Authored-By: Claude Sonnet 5 * feat: add Adobe IMS token fetch/cache module Implement expressAuth.js with getAccessToken and buildAuthHeaders functions. Provides IMS token caching with 60s refresh margin per Adobe IMS v3 convention. Co-Authored-By: Claude Sonnet 5 * feat: read the shared docID catalog instead of hardcoded seed images Co-Authored-By: Claude Sonnet 5 * fix: validate catalog is an array in loadCatalog() If the catalog file parses successfully but isn't an array (e.g., `{}` or `42`), treat it as an invalid catalog, log the error, and return empty array instead of crashing on `.map()`. Aligns with design spec requirement to degrade gracefully on catalog read failures. Co-Authored-By: Claude Sonnet 5 * feat: replace mocked Express API with real tagged-documents/generate-variation/status calls Co-Authored-By: Claude Sonnet 5 * feat: wire actions.js to the real Express API and drop mocked edit validation * feat: log webhook metadata fields and declare Express API env vars * Fix cross-task issues from final branch review - Use the real statusUrl returned by generateVariation instead of reconstructing a status URL from apiBaseUrl()+jobId, which was an unverified assumption about the API's URL shape. - Overlay image.currentEdits onto tagged elements before formatting allowed-edits messages so previously edited fields show their latest value instead of the stale original document value. - Narrow the hardcoded campaign graphics list to match the real single-entry catalog (Croma Earbuds). - Guard the sendImage call in actionEditGraphic with try/catch so a delivery failure doesn't swallow an already-successful edit; users now get a "couldn't send" message instead of no reply at all. Co-Authored-By: Claude Sonnet 5 * Add implementation plan for real Adobe Express API integration Co-Authored-By: Claude Sonnet 5 * Move source files into src/, group Express API modules under src/express/ Keeps data/ (shared catalog) and docs/ at repo root. Updates imageStore.js's default catalog path, actions.js/actions.test.js's expressApi import, and the app.js entry point in package.json/render.yaml to match. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- .gitignore | 1 + actions.js | 61 - actions.test.js | 66 -- data/express-templates.json | 3 + ...2026-07-16-real-express-api-integration.md | 1039 +++++++++++++++++ ...07-13-global-fixed-edit-response-design.md | 52 + ...-16-real-express-api-integration-design.md | 188 +++ expressApi.js | 33 - expressApi.test.js | 39 - imageStore.js | 28 - imageStore.test.js | 35 - package.json | 4 +- render.yaml | 18 +- src/actions.js | 100 ++ src/actions.test.js | 149 +++ app.js => src/app.js | 4 + src/express/expressApi.js | 102 ++ src/express/expressApi.test.js | 204 ++++ src/express/expressAuth.js | 44 + src/express/expressAuth.test.js | 90 ++ src/imageStore.js | 43 + src/imageStore.test.js | 75 ++ metaUpload.js => src/metaUpload.js | 0 metaUpload.test.js => src/metaUpload.test.js | 0 24 files changed, 2113 insertions(+), 265 deletions(-) delete mode 100644 actions.js delete mode 100644 actions.test.js create mode 100644 data/express-templates.json create mode 100644 docs/superpowers/plans/2026-07-16-real-express-api-integration.md create mode 100644 docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md create mode 100644 docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md delete mode 100644 expressApi.js delete mode 100644 expressApi.test.js delete mode 100644 imageStore.js delete mode 100644 imageStore.test.js create mode 100644 src/actions.js create mode 100644 src/actions.test.js rename app.js => src/app.js (96%) create mode 100644 src/express/expressApi.js create mode 100644 src/express/expressApi.test.js create mode 100644 src/express/expressAuth.js create mode 100644 src/express/expressAuth.test.js create mode 100644 src/imageStore.js create mode 100644 src/imageStore.test.js rename metaUpload.js => src/metaUpload.js (100%) rename metaUpload.test.js => src/metaUpload.test.js (100%) diff --git a/.gitignore b/.gitignore index fd4f2b066b..84888f4ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +.superpowers diff --git a/actions.js b/actions.js deleted file mode 100644 index a990e7b913..0000000000 --- a/actions.js +++ /dev/null @@ -1,61 +0,0 @@ -const { getTrackedImages, findTrackedImage } = require('./imageStore'); -const { getTemplateInfo, applyEdit } = require('./expressApi'); -const { uploadImageToMeta } = require('./metaUpload'); - -function formatUnknownImageMessage(phoneNumber) { - const images = getTrackedImages(phoneNumber); - const list = images.map((image) => `- ${image.name}`).join('\n'); - return `I couldn't find that image. Here's what I have:\n${list}`; -} - -async function actionListCampaignGraphics() { - // TODO: fetch from campaign API - return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; -} - -async function actionCheckAllowedEdits(phoneNumber, imageId) { - const image = findTrackedImage(phoneNumber, imageId); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - const { unlockedLayers } = getTemplateInfo(image.templateId); - const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); - return `Edits allowed on "${image.name}":\n${layerList}`; -} - -async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { - const image = findTrackedImage(phoneNumber, imageId); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - const { unlockedLayers } = getTemplateInfo(image.templateId); - const requestedKeys = Object.keys(edits || {}); - const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); - - if (disallowedKeys.length > 0) { - const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; - } - - const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); - image.currentEdits = mergedEdits; - - const uploadedUrl = await uploadImageToMeta(renderedImageUrl); - await sendImage(phoneNumber, uploadedUrl); - - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - return `Updated "${image.name}":\n${summary}`; -} - -async function actionGenerateBulkGraphics(filename) { - // TODO: parse CSV/Excel and call Adobe Express API per row - return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; -} - -module.exports = { - actionListCampaignGraphics, - actionCheckAllowedEdits, - actionEditGraphic, - actionGenerateBulkGraphics, -}; diff --git a/actions.test.js b/actions.test.js deleted file mode 100644 index fa88280a39..0000000000 --- a/actions.test.js +++ /dev/null @@ -1,66 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); -const { getTrackedImages } = require('./imageStore'); - -test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { - const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - assert.match(reply, /Diwali Offer Banner/); - assert.match(reply, /discount_text/); -}); - -test('actionCheckAllowedEdits lists Price, Address, Product Image, Partner Logo for the Croma earbuds image', async () => { - const reply = await actionCheckAllowedEdits('phone-croma', 'img_3'); - assert.match(reply, /Croma Earbuds/); - assert.match(reply, /- Price/); - assert.match(reply, /- Address/); - assert.match(reply, /- Product Image/); - assert.match(reply, /- Partner Logo/); -}); - -test('actionCheckAllowedEdits reports unknown images without throwing', async () => { - const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); - assert.match(reply, /couldn't find that image/); -}); - -test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { - let sendImageCalled = false; - const sendImage = async () => { - sendImageCalled = true; - }; - - const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); - - assert.match(reply, /can't edit background_color/); - assert.equal(sendImageCalled, false); -}); - -test('actionEditGraphic sends the fixed updated Croma earbuds image for any allowed edit', async () => { - const sentCalls = []; - const sendImage = async (to, link) => { - sentCalls.push({ to, link }); - }; - - const reply = await actionEditGraphic('phone-5', 'img_3', { Price: '999' }, { sendImage }); - - assert.match(reply, /Updated "Croma Earbuds"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].link, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); -}); - -test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { - const sentCalls = []; - const sendImage = async (to, link) => { - sentCalls.push({ to, link }); - }; - - const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); - - assert.match(reply, /Updated "Summer Sale Flyer"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].to, 'phone-4'); - assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); - - const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); - assert.equal(image.currentEdits.headline, 'Flash Sale'); -}); diff --git a/data/express-templates.json b/data/express-templates.json new file mode 100644 index 0000000000..79fb4b86fc --- /dev/null +++ b/data/express-templates.json @@ -0,0 +1,3 @@ +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] diff --git a/docs/superpowers/plans/2026-07-16-real-express-api-integration.md b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md new file mode 100644 index 0000000000..4762840bb3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md @@ -0,0 +1,1039 @@ +# Real Adobe Express API Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every mocked/hardcoded piece of the image-edit flow with real Adobe Express API calls (`tagged-documents`, `generate-variation`, `status`), driven by a shared `docId` catalog, ending with the real edited-image `thumbnailUrl` sent back over WhatsApp. + +**Architecture:** A new `expressAuth.js` handles IMS OAuth token fetch/cache. `expressApi.js` is rewritten to make real HTTP calls (tagged-document lookup, variation generation, status polling) plus small formatting/helper functions, using `expressAuth.js` for auth headers. `imageStore.js` is rewritten to read a shared JSON catalog (`data/express-templates.json`, `id`/`name`/`docId`) fresh on every call instead of hardcoded seed data, layering in-memory per-conversation edit state on top. `actions.js` is rewired to call the real `expressApi.js` functions (via a namespace import so tests can stub them directly) instead of the old mocks, with all Express API failures caught and turned into friendly WhatsApp replies. `app.js` gets a few extra labeled `console.log` lines for webhook fields that might carry docID metadata — no other behavior change. `metaUpload.js` is left completely untouched and unused. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` + global `fetch` (no new dependencies). + +## Global Constraints + +- No new npm dependencies — use Node's built-in `node:test` test runner and global `fetch`, stubbed directly in tests (no mocking library). +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. +- Real Adobe Express HTTP errors must never reach the WhatsApp user raw — every call site catches and replaces them with a short friendly retry message, logging the technical detail (status/body/docId/jobId) server-side via `console.error`. +- `data/express-templates.json` is committed to git (not gitignored) and is a flat, phone-number-agnostic catalog: `[{ id, name, docId }]`. No per-phone-number entries. +- IMS token responses' `expires_in` is treated as **milliseconds** (Adobe IMS v3 convention), added directly to `Date.now()` — flagged as an assumption to verify against the real endpoint; isolated to one line in `expressAuth.js` if it needs to change to `* 1000`. +- `metaUpload.js` and `metaUpload.test.js` stay in the repo, completely unmodified, and are not imported anywhere after this change. +- A failed or timed-out edit must not be recorded into the per-conversation edit state — only a successful `generate-variation` + `status: succeeded` commits the edit, so retries don't compound bad state. + +--- + +## Task 1: `expressAuth.js` — IMS token fetch/cache + +**Files:** +- Create: `expressAuth.js` +- Test: `expressAuth.test.js` + +**Interfaces:** +- Consumes: env vars `EXPRESS_CLIENT_ID`, `EXPRESS_CLIENT_SECRET`, `EXPRESS_API_SCOPE` (optional), `EXPRESS_IMS_TOKEN_URL` (optional). +- Produces: `getAccessToken() -> Promise` (cached IMS access token, refetches within 60s of expiry). `buildAuthHeaders() -> Promise<{ Authorization: string, 'X-API-KEY': string }>`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressAuth.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressAuth.test.js` +Expected: FAIL — `Cannot find module './expressAuth'` + +- [ ] **Step 3: Write the implementation** + +Create `expressAuth.js`: + +```js +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressAuth.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressAuth.js expressAuth.test.js +git commit -m "feat: add Adobe IMS token fetch/cache module" +``` + +--- + +## Task 2: `imageStore.js` — real catalog-backed image store + +**Files:** +- Create: `data/express-templates.json` +- Modify: `imageStore.js` (full rewrite) +- Modify: `imageStore.test.js` (full rewrite) + +**Interfaces:** +- Consumes: env var `EXPRESS_TEMPLATES_FILE` (optional, defaults to `data/express-templates.json` relative to this file). +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id, name, docId, currentEdits }>`, `findTrackedImage(phoneNumber, imageId) -> object | undefined`, `recordEdits(phoneNumber, imageId, newEdits) -> object` (merges and returns the new `currentEdits`). + +- [ ] **Step 1: Write the failing tests** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test imageStore.test.js` +Expected: FAIL — assertions fail against the old hardcoded `SEED_IMAGES` behavior (module exists but returns the wrong shape/values) + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `imageStore.js`: + +```js +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + return JSON.parse(raw); + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; +``` + +- [ ] **Step 4: Create the real catalog file** + +Create `data/express-templates.json` with the one confirmed real entry (the Diwali/Summer entries from the old mock had no real Express document behind them, so they're not carried forward as fake data — add more entries here once the UI app provides their real `docId`s): + +```json +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `node --test imageStore.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 6: Commit** + +```bash +git add imageStore.js imageStore.test.js data/express-templates.json +git commit -m "feat: read the shared docID catalog instead of hardcoded seed images" +``` + +--- + +## Task 3: `expressApi.js` — real Adobe Express HTTP calls + +**Files:** +- Modify: `expressApi.js` (full rewrite) +- Modify: `expressApi.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `buildAuthHeaders` from `./expressAuth` (Task 1); env vars `EXPRESS_API_BASE_URL`, `EXPRESS_STATUS_POLL_INTERVAL_MS`, `EXPRESS_STATUS_POLL_TIMEOUT_MS` (all optional). +- Produces: `getTaggedDocument(docId) -> Promise`, `generateVariation(docId, tagMappings, pages, preferredDocumentName) -> Promise<{ jobId, statusUrl }>`, `getJobStatus(jobId) -> Promise`, `pollJobStatus(jobId, { intervalMs?, timeoutMs? }) -> Promise`, `collectTaggedElements(taggedDocument) -> Array<{ name, type, value?, pageNumber }>`, `formatAllowedEdits(name, elements) -> string`, `pagesForEdits(elements, editKeys) -> string`, `buildPreferredDocumentName(baseName) -> string`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus returns the parsed status response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + })], + ]); + + const result = await getJobStatus('job-1'); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + let calls = 0; + stubFetch([ + [/\/status\//, async () => { + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus('job-2', { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-3', { intervalMs: 1, timeoutMs: 1000 }), /failed/); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-4', { intervalMs: 5, timeoutMs: 20 }), /timed out/); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressApi.test.js` +Expected: FAIL — old mocked `getTemplateInfo`/`applyEdit` exports don't match the new functions being imported + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `expressApi.js`: + +```js +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(jobId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/status/${encodeURIComponent(jobId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(jobId); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job ${jobId} failed`); + if (Date.now() >= deadline) throw new Error(`Express job ${jobId} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressApi.test.js` +Expected: PASS — 11 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: replace mocked Express API with real tagged-documents/generate-variation/status calls" +``` + +--- + +## Task 4: `actions.js` — wire the real edit flow + +**Files:** +- Modify: `actions.js` (full rewrite) +- Modify: `actions.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage`, `recordEdits` from `./imageStore` (Task 2); the `expressApi` module as a **namespace import** (`const expressApi = require('./expressApi')`, not destructured) from Task 3, specifically so tests can stub `expressApi.getTaggedDocument`/`generateVariation`/`pollJobStatus` directly without touching `global.fetch`. +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged), `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`, `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise`, `actionGenerateBulkGraphics(filename) -> Promise` (unchanged). + +- [ ] **Step 1: Write the failing tests** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./expressApi'); +const { findTrackedImage } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (jobId) => { + assert.equal(jobId, 'job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test actions.test.js` +Expected: FAIL — old `actions.js` still validates against `getTemplateInfo`/hardcoded layers and calls `metaUpload`, not matching these assertions + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `actions.js`: + +```js +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + return expressApi.formatAllowedEdits(image.name, elements); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elements)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { jobId } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(jobId); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + await sendImage(phoneNumber, thumbnailUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test actions.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `expressAuth.test.js`, `imageStore.test.js`, `expressApi.test.js`, `actions.test.js`, `metaUpload.test.js` passing + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: wire actions.js to the real Express API and drop mocked edit validation" +``` + +--- + +## Task 5: `app.js` webhook logging + `render.yaml` env vars + +**Files:** +- Modify: `app.js:197-202` (incoming message loop) +- Modify: `render.yaml` (envVars list) + +**Interfaces:** +- Consumes: nothing new — pure logging addition, no new imports. +- Produces: nothing new — verified manually (webhook route, no unit test, matching the existing pattern for `app.js`'s WhatsApp/webhook wiring). + +- [ ] **Step 1: Add labeled webhook logging** + +In `app.js`, inside the `for (const message of messages) {` loop in the `app.post('/')` handler, replace: + +```js + for (const message of messages) { + const userText = message?.text?.body; + if (!userText) continue; +``` + +with: + +```js + for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + + const userText = message?.text?.body; + if (!userText) continue; +``` + +- [ ] **Step 2: Add the new env vars to `render.yaml`** + +In `render.yaml`, add to the `envVars` list (after the existing `OPENAI_MODEL` entry): + +```yaml + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false +``` + +- [ ] **Step 3: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 4: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test EXPRESS_CLIENT_ID=test EXPRESS_CLIENT_SECRET=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 5: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all tests passing (same count as end of Task 4) + +- [ ] **Step 6: Commit** + +```bash +git add app.js render.yaml +git commit -m "feat: log webhook metadata fields and declare Express API env vars" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** IMS auth (Task 1), real `tagged-documents`/`generate-variation`/`status` calls (Task 3), shared `id`/`name`/`docId` catalog replacing hardcoded seed images (Task 2), simplified allowed-edits messaging and real-field validation (Tasks 3–4), generate→poll→send flow with `thumbnailUrl` sent directly (Task 4), `metaUpload.js` left in place and unused (Task 4, no import added), webhook metadata logging for docID investigation (Task 5), env var declarations (Task 5) — all covered. `actionListCampaignGraphics`/`actionGenerateBulkGraphics` and any new logic on `context`/`referral`/`image` fields are explicitly out of scope per the spec and untouched beyond logging. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from existing code (explicitly out of scope) and are intentional. +- **Type consistency:** `image.docId` (not `templateId`) used consistently across `imageStore.js` (Task 2), `expressApi.js` functions (Task 3), and `actions.js` (Task 4). `currentEdits` shape (`{ [tagName]: value }`) consistent between `imageStore.js`'s `recordEdits`/`getTrackedImages` and `actions.js`'s `mergedEdits` construction. `sendImage(to, link)` signature unchanged from the existing `app.js` implementation, matching its call site in Task 4. `expressApi` is a namespace import in `actions.js` (not destructured) specifically so `actions.test.js` can stub individual functions — noted in Task 4's Interfaces block so this doesn't look like an inconsistency with Task 3's plain destructure inside `expressApi.js` itself (that destructure is fine since `expressApi.test.js` stubs `global.fetch`, not `expressAuth`'s exports). diff --git a/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md new file mode 100644 index 0000000000..afcdc7cabb --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md @@ -0,0 +1,52 @@ +# Design: Global fixed responses for edit-graphic and check-allowed-edits + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md). + +## Goal + +Simplify two of the existing edit-flow actions in `actions.js` so their outputs are fixed, regardless of which image/template the request is about: + +1. **`edit_graphic`** — after any successful edit, always send the same hardcoded image (`https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated`) as a new WhatsApp image message to the requesting user. No more per-template mock render or Meta upload. +2. **`check_allowed_edits`** — always reply with the fixed text `Price, Partner logo, Partner's Address, product image`, regardless of which image is asked about. + +As a consequence, the "is this edit key allowed for this template" validation in `actionEditGraphic` is removed: any edit key is now accepted for any image. + +## Scope + +Only `actions.js` changes. `expressApi.js`, `imageStore.js`, `metaUpload.js`, and `app.js` are untouched — `expressApi.js` and `metaUpload.js` become unused by application code (no longer called from `actions.js`) but are left in place, along with their existing tests, as placeholders for a future real Adobe Express / Meta upload integration. + +## Changes to `actions.js` + +Two new constants: + +```js +const EDITED_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'; +const ALLOWED_EDITS_TEXT = "Price, Partner logo, Partner's Address, product image"; +``` + +`actionCheckAllowedEdits(phoneNumber, imageId)`: +- Keeps the unknown-image guard (`formatUnknownImageMessage`). +- For any known image, returns `ALLOWED_EDITS_TEXT` unconditionally — no more per-template `getTemplateInfo` lookup. + +`actionEditGraphic(phoneNumber, imageId, edits, { sendImage })`: +- Keeps the unknown-image guard. +- Drops the disallowed-key validation entirely (no `getTemplateInfo` call, no rejection path). +- Merges `edits` into `image.currentEdits` directly (`{ ...image.currentEdits, ...edits }`) instead of calling `expressApi.applyEdit` — the rendered URL it used to return is no longer needed since we always send `EDITED_IMAGE_URL`. +- Sends `EDITED_IMAGE_URL` via `sendImage` (no more `metaUpload.uploadImageToMeta` call, since this URL is already publicly hosted). +- Returns the same `Updated "":\n` confirmation text as before. + +Unused imports (`getTemplateInfo`, `applyEdit` from `expressApi.js`; `uploadImageToMeta` from `metaUpload.js`) are removed from `actions.js`. + +## Error handling + +Unchanged from the existing flow: an unknown `image_id` (GPT hallucination) still gets the friendly "I couldn't find that image" message in both actions, with no crash. There is no other error path left in `actionEditGraphic` now that key validation is removed. + +## Testing + +Update `actions.test.js`: +- `actionCheckAllowedEdits` tests for known images (Diwali, Croma) now assert the reply equals `ALLOWED_EDITS_TEXT`, for any tracked image — not a per-template layer list. +- The "rejects edits outside the unlocked layers" test is replaced with a test confirming a previously-disallowed key (e.g. `background_color` on the Croma image) is now accepted and sends the image. +- The edit tests (Croma image, Summer Sale Flyer image) both assert `sendImage` is called with `EDITED_IMAGE_URL`, for every template — not just Croma. +- The "remembers the edit" assertion (`image.currentEdits`) is unchanged. + +`expressApi.test.js` and `metaUpload.test.js` are untouched — those modules' own behavior isn't changing, only their (lack of) callers. diff --git a/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md new file mode 100644 index 0000000000..5505b2d7f0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md @@ -0,0 +1,188 @@ +# Design: Real Adobe Express API integration for the image-edit flow + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md) and [2026-07-13-global-fixed-edit-response-design.md](./2026-07-13-global-fixed-edit-response-design.md), replacing the mocked/fixed behavior those introduced with real Adobe Express API calls. + +## Goal + +Replace all hardcoding and mocked responses in the image-edit flow with real calls to the Adobe Express API: + +1. `GET /beta/tagged-documents/` to discover what's editable on a graphic and show the user a simplified list. +2. `POST /beta/generate-variation` to apply the user's requested edits. +3. `GET /status/` to poll until the variation is ready. +4. Send the resulting `thumbnailUrl` back to the user as a new WhatsApp image message. + +The identity of *which* Express document a tracked image maps to comes from a JSON catalog shared with the separate UI repo (out of scope for this change), not from anything invented in this backend. + +## Shared catalog: `data/express-templates.json` + +Committed to this repo (not gitignored), maintained in step with an equivalent file in the UI repo: + +```json +[ + { "id": "img_1", "name": "Diwali Offer Banner", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_2", "name": "Summer Sale Flyer", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_3", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- Flat array, `id`/`name`/`docId` only — no `phoneNumber`. It's a global catalog of known templates/images, not a per-customer sent-images log (the UI app's `sendWhatsAppTemplateMessage` already carries `docId` alongside `templateName` on its own send calls; this file is the read side this backend needs). +- Path is overridable via `EXPRESS_TEMPLATES_FILE` env var, defaulting to `data/express-templates.json`. +- Keeping both repos' copies of this file in sync (git submodule, manual copy, CI step, etc.) is out of scope for this change — flagged, not solved, here. + +## New module: `expressAuth.js` + +```js +async function getAccessToken() // returns a cached IMS access token, refreshing near expiry +async function buildAuthHeaders() // -> { Authorization: 'Bearer ', 'X-API-KEY': } +``` + +- `POST https://ims-na1.adobelogin.com/ims/token/v3` (overridable via `EXPRESS_IMS_TOKEN_URL`), `grant_type=client_credentials`, `client_id`/`client_secret` from `EXPRESS_CLIENT_ID`/`EXPRESS_CLIENT_SECRET`, `scope` from `EXPRESS_API_SCOPE` (default `ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext`, taken from a decoded sample token). +- `X-API-KEY` is `EXPRESS_CLIENT_ID` (confirmed identical to the sample token's `client_id` claim). +- Token cached in memory with its expiry; refetched once within 60s of expiry. +- **Assumption to verify on first real call:** Adobe IMS v3 token responses have historically returned `expires_in` in **milliseconds**, not seconds (unlike v2). This module treats `expires_in` as milliseconds directly (`expiresAt = Date.now() + Number(expires_in)`). If the real response turns out to be in seconds, this is a one-line fix isolated to this function. + +## Rewritten module: `expressApi.js` + +No more `TEMPLATE_LAYERS`/mock render. Real HTTP calls against `EXPRESS_API_BASE_URL` (default `https://express-api.adobe.io`), using `expressAuth.buildAuthHeaders()`: + +```js +async function getTaggedDocument(docId) +// GET /beta/tagged-documents/ +// -> { name, id, documentPages: [{ pageNumber, taggedElements: [{ name, type, value?, position, size }] }] } + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) +// POST /beta/generate-variation +// body: { id: docId, variationDetails: { pages, preferredDocumentName, tagMappings } } +// -> { jobId, statusUrl } + +async function getJobStatus(jobId) +// GET /status/ +// -> { jobId, status, document?: { name, id, thumbnailUrl } } + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) +// Polls getJobStatus every intervalMs (default EXPRESS_STATUS_POLL_INTERVAL_MS=2000) +// until status === 'succeeded' (returns the full result) or 'failed' (throws), +// or until timeoutMs elapses (default EXPRESS_STATUS_POLL_TIMEOUT_MS=60000, throws). +``` + +Non-2xx responses from any of the three GET/POST calls throw with the HTTP status and response body included in the error message, for logging by the caller. + +### Helpers (co-located in `expressApi.js`) + +```js +function collectTaggedElements(taggedDocument) +// Flattens documentPages[].taggedElements[] into one array, each tagged with its pageNumber. + +function formatAllowedEdits(name, elements) +// Simplified user-facing text, e.g.: +// Edits allowed on "Croma Earbuds": +// - heading: currently "The X-Phone Pro is here!" +// - cta: currently "Available at our store starting 15 Aug 20XX." +// Tell me what you'd like to change and to what, e.g. "change cta to ...". +// Non-text element types are listed as "- ()" without a current value. + +function pagesForEdits(elements, editKeys) +// -> comma-joined, ascending page numbers whose taggedElements include any of editKeys, e.g. "1" or "1,2". + +function buildPreferredDocumentName(baseName) +// -> `${baseName}-edit-${Date.now()}` +``` + +## Rewritten module: `imageStore.js` + +```js +function getTrackedImages(phoneNumber) +// Reads data/express-templates.json fresh on every call (no caching of the catalog itself), +// merges in this conversation's accumulated tagMappings. +// -> Array<{ id, name, docId, currentEdits }> + +function findTrackedImage(phoneNumber, imageId) +// -> single entry from getTrackedImages(phoneNumber), or undefined + +function recordEdits(phoneNumber, imageId, newEdits) +// Merges newEdits into the in-memory Map<"phone:id", tagMappings> and returns the merged object. +// Only called after a generate-variation call succeeds (see actions.js below) — +// a failed/timed-out edit does not get committed, so a retry starts from the last-known-good state. +``` + +Catalog read failures (missing/invalid file) are logged and treated as an empty catalog (no images tracked) rather than crashing the process. + +## `metaUpload.js` + +Left in place, untouched, exports unchanged — but no longer imported by `actions.js`. `generate-variation`'s `status` response already returns a public, directly-fetchable `thumbnailUrl`, so there's no upload hop needed. `metaUpload.test.js` stays as-is since the module's own behavior isn't changing. + +## Rewritten `actions.js` + +```js +async function actionCheckAllowedEdits(phoneNumber, imageId) { + // unknown-image guard unchanged (formatUnknownImageMessage) + // getTaggedDocument(image.docId) -> collectTaggedElements -> formatAllowedEdits(image.name, elements) + // Express API errors: log technical detail, return a generic friendly retry message (no crash, no raw error to the user) +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + // unknown-image guard unchanged + // getTaggedDocument(image.docId) -> collectTaggedElements -> allowedNames + // requestedKeys not in allowedNames -> same "I can't edit X, allowed edits are: " rejection as before, no Express/generate call made + // mergedEdits = { ...image.currentEdits, ...edits } + // pages = pagesForEdits(elements, Object.keys(mergedEdits)) + // preferredDocumentName = buildPreferredDocumentName(image.name) + // generateVariation(image.docId, mergedEdits, pages, preferredDocumentName) -> jobId + // pollJobStatus(jobId) -> result + // recordEdits(phoneNumber, imageId, edits) // only on success + // sendImage(phoneNumber, result.document.thumbnailUrl) + // generate/poll errors: log technical detail, return a generic friendly retry message + // Returns the same "Updated \"\":\n" confirmation text as before on success +} +``` + +`actionListCampaignGraphics` and `actionGenerateBulkGraphics` are unchanged (still out of scope — their existing `// TODO` mocks stay as-is). + +## `app.js` — webhook logging only + +No behavior change to the GPT tool flow. In the incoming-message loop, before the existing `if (!userText) continue`, add labeled logging for fields that might carry the UI app's per-message metadata, so a real test send lets you find where (if anywhere) a docID rides along in this app's webhook payload, independent of the `data/express-templates.json` catalog: + +```js +if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); +if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); +if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); +``` + +This is observability only — no new logic acts on these fields. Actually handling incoming image messages (or any other message type beyond text) stays out of scope, same as the original design. + +## Environment variables + +New, added to `render.yaml` (`sync: false` for secrets, matching existing `OPENAI_API_KEY` pattern): + +- `EXPRESS_CLIENT_ID` (required, secret) +- `EXPRESS_CLIENT_SECRET` (required, secret) +- `EXPRESS_API_SCOPE` (optional, has default) +- `EXPRESS_IMS_TOKEN_URL` (optional, has default) +- `EXPRESS_API_BASE_URL` (optional, has default) +- `EXPRESS_TEMPLATES_FILE` (optional, has default) +- `EXPRESS_STATUS_POLL_INTERVAL_MS` (optional, has default) +- `EXPRESS_STATUS_POLL_TIMEOUT_MS` (optional, has default) + +## Error handling + +- Unknown `image_id` (GPT hallucination): unchanged friendly message, no crash. +- Disallowed edit field(s): unchanged rejection listing what's actually allowed, no Express calls made — now driven by real `taggedElements` instead of hardcoded layers. +- Any Adobe Express HTTP error (auth failure, 404 doc not found, network error), and any poll timeout/failure: caught in `actions.js`, logged with technical detail (status, docId/jobId, message), and surfaced to the user as a short generic retry message — never a raw stack trace or API error body over WhatsApp. +- A failed/timed-out edit does not get merged into the conversation's `tagMappings`, so retrying re-sends the last-known-good edit set plus the new attempt, rather than compounding a bad state. + +## Testing + +No new npm dependencies — tests stub `global.fetch` directly (Node's built-in `node:test`, matching the existing pattern; `app.js` already relies on global `fetch` for WhatsApp calls). + +- `expressAuth.test.js` (new): fetches and caches a token; refetches once near-expiry; builds the right headers. +- `expressApi.test.js` (rewritten): `getTaggedDocument`/`generateVariation`/`getJobStatus` send the right URL/method/body/headers and parse responses correctly; `pollJobStatus` resolves on `succeeded`, throws on `failed`, throws on timeout without exceeding it; `collectTaggedElements`/`formatAllowedEdits`/`pagesForEdits`/`buildPreferredDocumentName` unit-tested directly against sample API response shapes. +- `imageStore.test.js` (rewritten): reads a fixture catalog file; `recordEdits` accumulates correctly per `(phoneNumber, imageId)`; missing/invalid catalog file degrades to an empty list without throwing. +- `actions.test.js` (rewritten): known-image allowed-edits listing; unknown-image guard; disallowed-field rejection (no generate call); happy path (generate → poll → `sendImage` called with `thumbnailUrl`, edits recorded); generate/poll failure path (friendly message, `sendImage` not called, edits not recorded). +- `metaUpload.test.js`: untouched. + +## Out of scope + +- Keeping the two repos' `data/express-templates.json` copies in sync. +- Any new logic acting on `message.context`/`message.referral`/`message.image` beyond logging them. +- `actionListCampaignGraphics` / `actionGenerateBulkGraphics` real implementations. +- Handling of non-text incoming WhatsApp message types beyond the added logging. diff --git a/expressApi.js b/expressApi.js deleted file mode 100644 index 0a352c054d..0000000000 --- a/expressApi.js +++ /dev/null @@ -1,33 +0,0 @@ -const TEMPLATE_LAYERS = { - tpl_diwali: ['discount_text', 'headline', 'background_color'], - tpl_summer: ['headline', 'font_color'], - tpl_croma_earbuds: ['Price', 'Address', 'Product Image', 'Partner Logo'], -}; - -function getTemplateInfo(templateId) { - return { - templateId, - unlockedLayers: TEMPLATE_LAYERS[templateId] || [], - }; -} - -let renderRevision = 0; - -function applyEdit(templateId, currentEdits, newEdits) { - const mergedEdits = { ...currentEdits, ...newEdits }; - - if (templateId === 'tpl_croma_earbuds') { - return { - mergedEdits, - renderedImageUrl: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated', - }; - } - - renderRevision += 1; - return { - mergedEdits, - renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, - }; -} - -module.exports = { getTemplateInfo, applyEdit }; diff --git a/expressApi.test.js b/expressApi.test.js deleted file mode 100644 index c9b3126535..0000000000 --- a/expressApi.test.js +++ /dev/null @@ -1,39 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { getTemplateInfo, applyEdit } = require('./expressApi'); - -test('getTemplateInfo returns the unlocked layers for a known template', () => { - const info = getTemplateInfo('tpl_diwali'); - assert.deepEqual(info, { - templateId: 'tpl_diwali', - unlockedLayers: ['discount_text', 'headline', 'background_color'], - }); -}); - -test('getTemplateInfo returns the unlocked layers for the Croma earbuds template', () => { - const info = getTemplateInfo('tpl_croma_earbuds'); - assert.deepEqual(info, { - templateId: 'tpl_croma_earbuds', - unlockedLayers: ['Price', 'Address', 'Product Image', 'Partner Logo'], - }); -}); - -test('getTemplateInfo returns an empty layer list for an unknown template', () => { - const info = getTemplateInfo('tpl_does_not_exist'); - assert.deepEqual(info.unlockedLayers, []); -}); - -test('applyEdit merges new edits on top of current edits', () => { - const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); - assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); -}); - -test('applyEdit returns a rendered image url that references the template', () => { - const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); - assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); -}); - -test('applyEdit returns the fixed updated Croma earbuds image for any edit', () => { - const result = applyEdit('tpl_croma_earbuds', {}, { Price: '999' }); - assert.equal(result.renderedImageUrl, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); -}); diff --git a/imageStore.js b/imageStore.js deleted file mode 100644 index 136d365bc8..0000000000 --- a/imageStore.js +++ /dev/null @@ -1,28 +0,0 @@ -const SEED_IMAGES = [ - { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, - { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, - { - id: 'img_3', - name: 'Croma Earbuds', - templateId: 'tpl_croma_earbuds', - url: 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds', - }, -]; - -const trackedImages = new Map(); - -function getTrackedImages(phoneNumber) { - if (!trackedImages.has(phoneNumber)) { - trackedImages.set( - phoneNumber, - SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) - ); - } - return trackedImages.get(phoneNumber); -} - -function findTrackedImage(phoneNumber, imageId) { - return getTrackedImages(phoneNumber).find((image) => image.id === imageId); -} - -module.exports = { getTrackedImages, findTrackedImage }; diff --git a/imageStore.test.js b/imageStore.test.js deleted file mode 100644 index 841acf9d78..0000000000 --- a/imageStore.test.js +++ /dev/null @@ -1,35 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { getTrackedImages, findTrackedImage } = require('./imageStore'); - -test('getTrackedImages seeds 3 images on first access', () => { - const images = getTrackedImages('111'); - assert.equal(images.length, 3); - assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); - assert.deepEqual(images[0].currentEdits, {}); -}); - -test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { - const first = getTrackedImages('222'); - first[0].currentEdits.headline = 'Flash Sale'; - const second = getTrackedImages('222'); - assert.equal(second[0].currentEdits.headline, 'Flash Sale'); -}); - -test('getTrackedImages seeds independently per phone number', () => { - getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; - const other = getTrackedImages('444'); - assert.deepEqual(other[0].currentEdits, {}); -}); - -test('findTrackedImage returns the matching image', () => { - getTrackedImages('555'); - const image = findTrackedImage('555', 'img_2'); - assert.equal(image.name, 'Summer Sale Flyer'); -}); - -test('findTrackedImage returns undefined for an unknown id', () => { - getTrackedImages('666'); - const image = findTrackedImage('666', 'img_999'); - assert.equal(image, undefined); -}); diff --git a/package.json b/package.json index 854ad81ab5..f68ca9be99 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "express-hello-world", "version": "1.0.0", "description": "Express Hello World on Render", - "main": "app.js", + "main": "src/app.js", "repository": "https://github.com/render-examples/express-hello-world", "author": "Render Developers", "license": "MIT", "private": false, "scripts": { - "start": "node app.js", + "start": "node src/app.js", "test": "node --test" }, "dependencies": { diff --git a/render.yaml b/render.yaml index 55fe1154ed..869c1badac 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,7 @@ services: runtime: node plan: free buildCommand: yarn install --frozen-lockfile - startCommand: node app.js + startCommand: node src/app.js envVars: - key: NODE_ENV value: production @@ -20,3 +20,19 @@ services: sync: false - key: OPENAI_MODEL sync: false + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000000..9c6e29fa46 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,100 @@ +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./express/expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Croma Earbuds'; +} + +function withCurrentEdits(elements, currentEdits) { + return elements.map((element) => + element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element + ); +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[actionEditGraphic] sendImage error', { docId: image.docId, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; diff --git a/src/actions.test.js b/src/actions.test.js new file mode 100644 index 0000000000..a662e30598 --- /dev/null +++ b/src/actions.test.js @@ -0,0 +1,149 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./express/expressApi'); +const { findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + recordEdits('phone-1b', 'img_1', { cta: '20% off' }); + + const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); + + assert.match(reply, /cta: currently "20% off"/); + assert.doesNotMatch(reply, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (statusUrl) => { + assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); + +test('actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); + + const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + + const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /couldn't send the image right now/); + assert.doesNotMatch(reply, /something went wrong generating/); + + const image = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); diff --git a/app.js b/src/app.js similarity index 96% rename from app.js rename to src/app.js index 1d703d50e1..1baa2ef9d1 100644 --- a/app.js +++ b/src/app.js @@ -195,6 +195,10 @@ app.post('/', async (req, res) => { if (!messages?.length) return; for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + const userText = message?.text?.body; if (!userText) continue; diff --git a/src/express/expressApi.js b/src/express/expressApi.js new file mode 100644 index 0000000000..2be744773e --- /dev/null +++ b/src/express/expressApi.js @@ -0,0 +1,102 @@ +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(statusUrl) { + const headers = await buildAuthHeaders(); + const response = await fetch(statusUrl, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(statusUrl, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(statusUrl); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job at ${statusUrl} failed`); + if (Date.now() >= deadline) throw new Error(`Express job at ${statusUrl} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; diff --git a/src/express/expressApi.test.js b/src/express/expressApi.test.js new file mode 100644 index 0000000000..c0b974d0c7 --- /dev/null +++ b/src/express/expressApi.test.js @@ -0,0 +1,204 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus fetches the exact statusUrl provided, with no reconstruction', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-1'; + stubFetch([ + [/\/status\/job-1$/, async (url) => { + assert.equal(url, statusUrl); + return { + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + }; + }], + ]); + + const result = await getJobStatus(statusUrl); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-2'; + let calls = 0; + stubFetch([ + [/\/status\/job-2$/, async (url) => { + assert.equal(url, statusUrl); + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-3'; + stubFetch([ + [/\/status\/job-3$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }), + (err) => err.message === `Express job at ${statusUrl} failed` + ); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-4'; + stubFetch([ + [/\/status\/job-4$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 5, timeoutMs: 20 }), + (err) => /timed out/.test(err.message) && err.message.includes(statusUrl) + ); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); diff --git a/src/express/expressAuth.js b/src/express/expressAuth.js new file mode 100644 index 0000000000..a5dfb262c6 --- /dev/null +++ b/src/express/expressAuth.js @@ -0,0 +1,44 @@ +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; diff --git a/src/express/expressAuth.test.js b/src/express/expressAuth.test.js new file mode 100644 index 0000000000..b9b7012386 --- /dev/null +++ b/src/express/expressAuth.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); diff --git a/src/imageStore.js b/src/imageStore.js new file mode 100644 index 0000000000..a2d75c0d02 --- /dev/null +++ b/src/imageStore.js @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, '..', 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: 'catalog is not an array' }); + return []; + } + return parsed; + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js new file mode 100644 index 0000000000..f175afb835 --- /dev/null +++ b/src/imageStore.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('getTrackedImages returns an empty list when the catalog is not an array', () => { + writeFixtureCatalog({}); + + const images = getTrackedImages('phone-2-non-array'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); diff --git a/metaUpload.js b/src/metaUpload.js similarity index 100% rename from metaUpload.js rename to src/metaUpload.js diff --git a/metaUpload.test.js b/src/metaUpload.test.js similarity index 100% rename from metaUpload.test.js rename to src/metaUpload.test.js From 4e7f47d546bd7084a26581f47a2b7101d20e82d9 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Tue, 21 Jul 2026 09:50:04 +0530 Subject: [PATCH 10/38] Cap discounts at 40%, add Hindi translation guidance, update graphic catalog - Reject edit_graphic requests with a discount field over 40%, telling the user the real cap instead of silently clamping the value. - Instruct GPT to translate tag text into Devanagari script (not transliteration) when the user asks for a Hindi translation. - Rename catalog entry to "Croma Diwali offer" with its correct docId. - Ignore .env locally. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + data/express-templates.json | 2 +- src/actions.js | 21 +++++++++++++++++++++ src/app.js | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 84888f4ca4..154f92d291 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .DS_Store .superpowers +.env diff --git a/data/express-templates.json b/data/express-templates.json index 79fb4b86fc..909564c8a0 100644 --- a/data/express-templates.json +++ b/data/express-templates.json @@ -1,3 +1,3 @@ [ - { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } + { "id": "img_1", "name": "Croma Diwali offer", "docId": "urn:aaid:sc:AP:4b4b25bb-0949-588e-bd7b-76d0e9a113bd" } ] diff --git a/src/actions.js b/src/actions.js index 9c6e29fa46..18ebcd6ebc 100644 --- a/src/actions.js +++ b/src/actions.js @@ -18,6 +18,17 @@ function withCurrentEdits(elements, currentEdits) { ); } +const MAX_DISCOUNT_PERCENT = 40; + +function isDiscountField(name) { + return /discount/i.test(name); +} + +function parsePercent(value) { + const match = String(value).match(/(\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; +} + async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); if (!image) { @@ -59,6 +70,16 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; } + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + if (oversizedDiscountKeys.length > 0) { + return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + } + const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); diff --git a/src/app.js b/src/app.js index 1baa2ef9d1..cce25232eb 100644 --- a/src/app.js +++ b/src/app.js @@ -106,7 +106,7 @@ const tools = [ function: { name: 'edit_graphic', description: - 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi, always use Devanagari script (e.g. "उपलब्ध"), never a romanized transliteration.', parameters: { type: 'object', properties: { @@ -149,6 +149,7 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. +If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, the translation must be in Devanagari script (e.g. "उपलब्ध"), not a romanized/transliterated form. Images previously sent to this user (reference by id): ${imagesList}`, From 3909999015ca64ae6bfc470422ede47c8b498da5 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:08:07 +0530 Subject: [PATCH 11/38] Turn allowed-edits reply into tappable quick-reply buttons (#6) Sends "Change heading / price / discount / product" as WhatsApp interactive buttons (or a list message when there are more than 3 options) instead of a plain text message, and handles the incoming button/list tap the same way as free-text input. Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/actions.js | 7 ++++- src/app.js | 64 ++++++++++++++++++++++++++++++++++++--- src/express/expressApi.js | 22 ++++++++++++-- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/actions.js b/src/actions.js index 18ebcd6ebc..346f3606ad 100644 --- a/src/actions.js +++ b/src/actions.js @@ -39,7 +39,12 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { const doc = await expressApi.getTaggedDocument(image.docId); const elements = expressApi.collectTaggedElements(doc); const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits); + return { + type: 'edit_options', + bodyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits, { includeInstruction: false }), + options: expressApi.buildEditOptions(elementsWithCurrentEdits), + historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; } catch (err) { console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; diff --git a/src/app.js b/src/app.js index cce25232eb..1381172a04 100644 --- a/src/app.js +++ b/src/app.js @@ -61,6 +61,50 @@ function sendImage(to, link) { return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); } +// WhatsApp reply-button messages support at most 3 buttons. +function sendButtons(to, bodyText, options) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'button', + body: { text: bodyText }, + action: { + buttons: options.map((option) => ({ type: 'reply', reply: { id: option.id, title: option.title } })), + }, + }, + }); +} + +function sendList(to, bodyText, buttonText, options) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ title: 'Edits', rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} + +// Sends the list of allowed edits as tappable options rather than plain text, +// falling back to a list message when there are more than fit in reply buttons. +async function sendEditOptions(to, { bodyText, options }) { + if (options.length === 0) { + await sendText(to, bodyText); + } else if (options.length > 3) { + await sendList(to, bodyText, 'Choose a field', options); + } else { + await sendButtons(to, bodyText, options); + } +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -200,7 +244,8 @@ app.post('/', async (req, res) => { if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); - const userText = message?.text?.body; + const interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; + const userText = message?.text?.body || interactiveReply?.title; if (!userText) continue; const phoneNumber = message.from; @@ -217,6 +262,7 @@ app.post('/', async (req, res) => { console.log(`GPT chose action: ${action}`, args); let replyText; + let skipSend = false; switch (action) { case 'list_campaign_graphics': @@ -228,10 +274,18 @@ app.post('/', async (req, res) => { replyText = args.question; break; - case 'check_allowed_edits': + case 'check_allowed_edits': { await sendText(phoneNumber, '⏳ Checking allowed edits...'); - replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + const result = await actionCheckAllowedEdits(phoneNumber, args.image_id); + if (typeof result === 'string') { + replyText = result; + } else { + await sendEditOptions(phoneNumber, result); + replyText = result.historyText; + skipSend = true; + } break; + } case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); @@ -247,7 +301,9 @@ app.post('/', async (req, res) => { replyText = "Sorry, I couldn't figure out how to handle that request."; } - await sendText(phoneNumber, replyText); + if (!skipSend) { + await sendText(phoneNumber, replyText); + } appendHistory(phoneNumber, 'assistant', replyText); } } catch (err) { diff --git a/src/express/expressApi.js b/src/express/expressApi.js index 2be744773e..ed28b817de 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -69,14 +69,29 @@ function collectTaggedElements(taggedDocument) { return elements; } -function formatAllowedEdits(name, elements) { +function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { const lines = elements.map((element) => element.type === 'text' ? `- ${element.name}: currently "${element.value}"` : `- ${element.name} (${element.type})` ); - const example = elements[0]?.name || 'a field'; - return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; + if (includeInstruction) { + const example = elements[0]?.name || 'a field'; + text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + } + return text; +} + +function humanizeFieldName(name) { + return name.replace(/_(text|image)$/i, '').replace(/_/g, ' '); +} + +function buildEditOptions(elements) { + return elements.map((element) => ({ + id: `edit:${element.name}`, + title: `Change ${humanizeFieldName(element.name)}`.slice(0, 20), + })); } function pagesForEdits(elements, editKeys) { @@ -97,6 +112,7 @@ module.exports = { pollJobStatus, collectTaggedElements, formatAllowedEdits, + buildEditOptions, pagesForEdits, buildPreferredDocumentName, }; From 540a43ee05a8fe22809c14dd08c0245512d1d084 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:48:18 +0530 Subject: [PATCH 12/38] Feat/edit options quick reply buttons (#7) * Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 * Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/actions.js | 2 +- src/app.js | 29 ++++++++--------------------- src/express/expressApi.js | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/actions.js b/src/actions.js index 346f3606ad..de92313c45 100644 --- a/src/actions.js +++ b/src/actions.js @@ -41,7 +41,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); return { type: 'edit_options', - bodyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits, { includeInstruction: false }), + bodyText: 'What would you like to change?', options: expressApi.buildEditOptions(elementsWithCurrentEdits), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; diff --git a/src/app.js b/src/app.js index 1381172a04..b27d607925 100644 --- a/src/app.js +++ b/src/app.js @@ -77,31 +77,18 @@ function sendButtons(to, bodyText, options) { }); } -function sendList(to, bodyText, buttonText, options) { - return whatsappPost({ - messaging_product: 'whatsapp', - to, - type: 'interactive', - interactive: { - type: 'list', - body: { text: bodyText }, - action: { - button: buttonText, - sections: [{ title: 'Edits', rows: options.map((option) => ({ id: option.id, title: option.title })) }], - }, - }, - }); -} +// WhatsApp reply-button messages cap out at 3 buttons, so options beyond that +// go out as additional button messages rather than falling back to a list picker. +const BUTTONS_PER_MESSAGE = 3; -// Sends the list of allowed edits as tappable options rather than plain text, -// falling back to a list message when there are more than fit in reply buttons. async function sendEditOptions(to, { bodyText, options }) { if (options.length === 0) { await sendText(to, bodyText); - } else if (options.length > 3) { - await sendList(to, bodyText, 'Choose a field', options); - } else { - await sendButtons(to, bodyText, options); + return; + } + for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { + const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); + await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); } } diff --git a/src/express/expressApi.js b/src/express/expressApi.js index ed28b817de..cb7ff9a857 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -84,13 +84,25 @@ function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) } function humanizeFieldName(name) { - return name.replace(/_(text|image)$/i, '').replace(/_/g, ' '); + return name + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); +} + +// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary +// rather than cutting mid-word. +function truncateTitle(title, maxLength = 20) { + if (title.length <= maxLength) return title; + const truncated = title.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } function buildEditOptions(elements) { return elements.map((element) => ({ id: `edit:${element.name}`, - title: `Change ${humanizeFieldName(element.name)}`.slice(0, 20), + title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } From 13d3bffec2ca1f8ee7a8fd9cd8e580f27da99998 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Tue, 21 Jul 2026 23:05:16 +0530 Subject: [PATCH 13/38] feat: add create_design flow for brand-new creatives (Onam) (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Flow 2.2 — creating a brand-new creative from a text description, assembled from approved brand elements, with canned images. - add create_design tool + action; registers a per-phone, in-memory source: 'local' design backed by canned image URLs (data/onam-design.json) - route edit_graphic / check_allowed_edits on image source: local designs skip the Adobe Express API; catalog designs keep the existing Express path - palette guardrail: reject off-palette background edits and offer the approved festive accents (Marigold / Maroon / Deep Green) - Malayalam banner translation via canned image (detect Malayalam script) - tighten tool descriptions + system prompt so messages that carry concrete values route to edit_graphic instead of check_allowed_edits - add one log line per action for observability - tests: cover create/edit/guardrail/translation; update stale expectations Co-authored-by: varun kalra Co-authored-by: Claude Opus 4.8 (1M context) --- data/onam-design.json | 21 ++++++ src/actions.js | 142 +++++++++++++++++++++++++++++++++++++-- src/actions.test.js | 12 ++-- src/app.js | 37 +++++++++- src/createDesign.test.js | 104 ++++++++++++++++++++++++++++ src/imageStore.js | 20 +++++- src/imageStore.test.js | 2 +- 7 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 data/onam-design.json create mode 100644 src/createDesign.test.js diff --git a/data/onam-design.json b/data/onam-design.json new file mode 100644 index 0000000000..5cc39839c9 --- /dev/null +++ b/data/onam-design.json @@ -0,0 +1,21 @@ +{ + "_comment": "Template used by the create_design tool for brand-new (source: 'local') creatives. Swap the placeholder image URLs below with your hosted PNG URLs. See README / actions.js for how they map to edit state.", + "images": { + "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base", + "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final", + "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam" + }, + "palette": [ + { "name": "Marigold", "hex": "#F4A300" }, + { "name": "Maroon", "hex": "#800020" }, + { "name": "Deep Green", "hex": "#1B5E20" } + ], + "slots": { + "editable": [ + { "name": "headline", "type": "text", "aliases": ["heading", "title", "header", "banner_text", "text"] }, + { "name": "background", "type": "color", "aliases": ["background_color", "bg", "colour", "color", "background_colour"] }, + { "name": "address", "type": "text", "aliases": ["store_address", "location", "store"] } + ], + "locked": ["logo", "product", "background_image"] + } +} diff --git a/src/actions.js b/src/actions.js index de92313c45..4fdbfb0db5 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,6 +1,13 @@ -const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const fs = require('node:fs'); +const path = require('node:path'); +const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); const expressApi = require('./express/expressApi'); +function loadOnamDesign() { + const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + function formatUnknownImageMessage(phoneNumber) { const images = getTrackedImages(phoneNumber); const list = images.map((image) => `- ${image.name}`).join('\n'); @@ -8,10 +15,31 @@ function formatUnknownImageMessage(phoneNumber) { } async function actionListCampaignGraphics() { + console.log('[action:list_campaign_graphics]'); // TODO: fetch from campaign API return 'Graphics in your current campaign:\n1. Croma Earbuds'; } +// Create a brand-new creative from a text description (Flow 2.2 — Onam). +// Unlike the catalog designs, this has no Adobe Express document; it is a +// `source: 'local'` design that resolves to canned images (see data/onam-design.json). +async function actionCreateDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { + const design = loadOnamDesign(); + const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); + const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); + createDesign(phoneNumber, { name, design }); + + try { + await sendImage(phoneNumber, design.images.base); + } catch (err) { + console.error('[actionCreateDesign] sendImage error', { message: err.message }); + return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + } + + return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; +} + function withCurrentEdits(elements, currentEdits) { return elements.map((element) => element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element @@ -29,12 +57,102 @@ function parsePercent(value) { return match ? Number(match[1]) : null; } +// ── Local (canned-image) design helpers — Flow 2.2 ─────────────────────────── + +function normalizeKey(key) { + return String(key).toLowerCase().trim().replace(/\s+/g, '_'); +} + +// Map GPT's free-form edit keys onto the design's canonical slot names via +// aliases, so "background_color" / "colour" / "heading" all resolve correctly. +function canonicalizeEdits(editableSlots, edits) { + const canonical = {}; + const unknown = []; + for (const [rawKey, value] of Object.entries(edits || {})) { + const key = normalizeKey(rawKey); + const slot = editableSlots.find( + (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) + ); + if (slot) canonical[slot.name] = value; + else unknown.push(rawKey); + } + return { canonical, unknown }; +} + +function isPaletteColor(design, value) { + const v = String(value).trim().toLowerCase(); + return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); +} + +function hasMalayalam(value) { + return /[ഀ-ൿ]/.test(String(value)); +} + +// Pick the canned image for the current accumulated edit state. +function resolveLocalImage(design, currentEdits) { + const values = Object.values(currentEdits); + if (values.some(hasMalayalam)) return design.images.malayalam; + if (currentEdits.background || currentEdits.address) return design.images.final; + return design.images.base; +} + +function localEditElements(image) { + return image.design.slots.editable.map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { + const design = image.design; + const editableSlots = design.slots.editable; + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + recordEdits(phoneNumber, image.id, edits); + const currentEdits = { ...image.currentEdits, ...edits }; + const imageUrl = resolveLocalImage(design, currentEdits); + console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, imageUrl); + } catch (err) { + console.error('[editLocalDesign] sendImage error', { imageId: image.id, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); if (!image) { return formatUnknownImageMessage(phoneNumber); } + if (image.source === 'local') { + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: expressApi.buildEditOptions(elements), + historyText: expressApi.formatAllowedEdits(image.name, elements), + }; + } + try { const doc = await expressApi.getTaggedDocument(image.docId); const elements = expressApi.collectTaggedElements(doc); @@ -51,18 +169,29 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } } +// Routes to the right edit path based on where the design came from: +// source: 'local' → canned-image design (Flow 2.2), no Express calls +// source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); if (!image) { return formatUnknownImageMessage(phoneNumber); } + if (image.source === 'local') { + return editLocalDesign(phoneNumber, image, edits, { sendImage }); + } + return editExpressDesign(phoneNumber, image, edits, { sendImage }); +} + +async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); elements = expressApi.collectTaggedElements(doc); } catch (err) { - console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; } @@ -94,19 +223,20 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); const result = await expressApi.pollJobStatus(statusUrl); thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); } catch (err) { - console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; } - recordEdits(phoneNumber, imageId, edits); + recordEdits(phoneNumber, image.id, edits); const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); try { await sendImage(phoneNumber, thumbnailUrl); } catch (err) { - console.error('[actionEditGraphic] sendImage error', { docId: image.docId, message: err.message }); + console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; } @@ -114,12 +244,14 @@ async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { } async function actionGenerateBulkGraphics(filename) { + console.log('[action:generate_bulk_graphics]', { filename }); // TODO: parse CSV/Excel and call Adobe Express API per row return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; } module.exports = { actionListCampaignGraphics, + actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, diff --git a/src/actions.test.js b/src/actions.test.js index a662e30598..1b16b26ced 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -34,9 +34,9 @@ test('actionCheckAllowedEdits lists the tagged elements for a known image', asyn const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - assert.match(reply, /Croma Earbuds/); - assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply, /cta: currently/); + assert.match(reply.historyText, /Croma Earbuds/); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently/); }); test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { @@ -46,9 +46,9 @@ test('actionCheckAllowedEdits shows the latest edited value instead of the stale const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); - assert.match(reply, /cta: currently "20% off"/); - assert.doesNotMatch(reply, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently "20% off"/); + assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); }); test('actionCheckAllowedEdits reports unknown images without throwing', async () => { diff --git a/src/app.js b/src/app.js index b27d607925..fe965dcff9 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,7 @@ const OpenAI = require('openai'); const { getTrackedImages } = require('./imageStore'); const { actionListCampaignGraphics, + actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, @@ -103,6 +104,27 @@ const tools = [ parameters: { type: 'object', properties: {} }, }, }, + { + type: 'function', + function: { + name: 'create_design', + description: + 'Create a brand-new single marketing creative from a text description, for an occasion or theme that has NO existing template (e.g. Onam, Pongal). Use when the user wants to make/create/design a new banner or creative from scratch. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', + parameters: { + type: 'object', + properties: { + occasion: { type: 'string', description: 'The occasion or theme, e.g. "Onam"' }, + products: { + type: 'array', + items: { type: 'string' }, + description: 'Products to feature, e.g. ["LG washing machine", "dishwasher"]', + }, + offer: { type: 'string', description: 'The offer or discount to show, e.g. "20% off"' }, + }, + required: ['occasion'], + }, + }, + }, { type: 'function', function: { @@ -122,7 +144,7 @@ const tools = [ function: { name: 'check_allowed_edits', description: - 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + 'Show the list of fields the user CAN edit on a graphic. Use this ONLY when the user asks what can be changed / wants the options (e.g. "what can I edit?", "edit", "make changes") and does NOT give a specific new value. If the user already states a change and its value, use edit_graphic instead. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', parameters: { type: 'object', properties: { @@ -137,7 +159,7 @@ const tools = [ function: { name: 'edit_graphic', description: - 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi, always use Devanagari script (e.g. "उपलब्ध"), never a romanized transliteration.', + 'Apply one or more concrete edits to a specific graphic. Use this WHENEVER the user states what to change AND the new value — e.g. "make the background marigold", "add my address MG Road Kochi", "change the offer to 7500", "translate the banner to Malayalam". Put every requested change into the edits object (multiple keys allowed). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to. If the user asks to translate a tag\'s text into another language, translate it yourself and pass the translated string as the edit value — for Hindi always use Devanagari script (e.g. "उपलब्ध"), for Malayalam always use Malayalam script (e.g. "ഓണം"), never a romanized transliteration.', parameters: { type: 'object', properties: { @@ -180,7 +202,10 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. -If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, the translation must be in Devanagari script (e.g. "उपलब्ध"), not a romanized/transliterated form. +If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. +Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. +When editing, prefer these field names when they apply: headline, background, address, offer. +If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. Images previously sent to this user (reference by id): ${imagesList}`, @@ -257,7 +282,13 @@ app.post('/', async (req, res) => { replyText = await actionListCampaignGraphics(); break; + case 'create_design': + await sendText(phoneNumber, '🎨 Creating your design, this may take a moment...'); + replyText = await actionCreateDesign(phoneNumber, args, { sendImage }); + break; + case 'ask_for_more_information': + console.log('[action:ask_for_more_information]', { question: args.question }); replyText = args.question; break; diff --git a/src/createDesign.test.js b/src/createDesign.test.js new file mode 100644 index 0000000000..da5adf0c54 --- /dev/null +++ b/src/createDesign.test.js @@ -0,0 +1,104 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCreateDesign, actionEditGraphic } = require('./actions'); + +const FIXTURE = { + images: { + base: 'https://cdn.test/onam-base.png', + final: 'https://cdn.test/onam-final.png', + malayalam: 'https://cdn.test/onam-malayalam.png', + }, + palette: [ + { name: 'Marigold', hex: '#F4A300' }, + { name: 'Maroon', hex: '#800020' }, + { name: 'Deep Green', hex: '#1B5E20' }, + ], + slots: { + editable: [ + { name: 'headline', type: 'text', aliases: ['heading', 'title'] }, + { name: 'background', type: 'color', aliases: ['background_color', 'colour', 'color'] }, + { name: 'address', type: 'text', aliases: ['store_address'] }, + ], + locked: ['logo', 'product'], + }, +}; + +function useFixture() { + const p = path.join(os.tmpdir(), `onam-design-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(FIXTURE)); + process.env.ONAM_DESIGN_FILE = p; +} + +function captureSendImage() { + const sent = []; + return { sendImage: async (_to, link) => sent.push(link), sent }; +} + +test('actionCreateDesign registers a local design and sends the base image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + const reply = await actionCreateDesign( + 'onam-phone-1', + { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, + { sendImage } + ); + + assert.equal(sent[0], FIXTURE.images.base); + assert.match(reply, /Built with Croma's logo/); +}); + +test('editing a created design to an off-palette background is rejected with approved options', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-2', { occasion: 'Onam' }, { sendImage }); + + const reply = await actionEditGraphic('onam-phone-2', 'local_1', { background: 'Pink' }, { sendImage }); + + assert.match(reply, /isn't in the approved palette/); + assert.match(reply, /Marigold · Maroon · Deep Green/); + assert.equal(sent.length, 1); // only the create image; no new image on a rejected edit +}); + +test('an approved-palette background + address resolves to the final image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-3', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-3', 'local_1', { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); + +test('a Malayalam headline resolves to the Malayalam image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-4', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-4', 'local_1', { headline: 'ഓണം ആശംസകൾ' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); +}); + +test('editing a locked element on a created design is refused', async () => { + useFixture(); + const { sendImage } = captureSendImage(); + await actionCreateDesign('onam-phone-5', { occasion: 'Onam' }, { sendImage }); + + const reply = await actionEditGraphic('onam-phone-5', 'local_1', { logo: 'brighter' }, { sendImage }); + + assert.match(reply, /locked by HQ/); +}); + +test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-6', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-6', 'local_1', { colour: 'Deep Green' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); diff --git a/src/imageStore.js b/src/imageStore.js index a2d75c0d02..c9dd11ce65 100644 --- a/src/imageStore.js +++ b/src/imageStore.js @@ -22,8 +22,24 @@ function loadCatalog() { const conversationEdits = new Map(); +// Brand-new designs created at runtime via create_design, keyed by phone number. +// These are `source: 'local'` (canned images) as opposed to the `source: 'express'` +// catalog entries which are backed by the real Adobe Express API. +const createdDesigns = new Map(); + +function createDesign(phoneNumber, design) { + const list = createdDesigns.get(phoneNumber) || []; + const id = `local_${list.length + 1}`; + const entry = { id, source: 'local', ...design }; + list.push(entry); + createdDesigns.set(phoneNumber, list); + return entry; +} + function getTrackedImages(phoneNumber) { - return loadCatalog().map((entry) => ({ + const catalog = loadCatalog().map((entry) => ({ ...entry, source: 'express' })); + const created = createdDesigns.get(phoneNumber) || []; + return [...catalog, ...created].map((entry) => ({ ...entry, currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, })); @@ -40,4 +56,4 @@ function recordEdits(phoneNumber, imageId, newEdits) { return merged; } -module.exports = { getTrackedImages, findTrackedImage, recordEdits }; +module.exports = { getTrackedImages, findTrackedImage, recordEdits, createDesign }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js index f175afb835..d15b37d2be 100644 --- a/src/imageStore.test.js +++ b/src/imageStore.test.js @@ -20,7 +20,7 @@ test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { const images = getTrackedImages('phone-1'); assert.equal(images.length, 2); - assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', source: 'express', currentEdits: {} }); }); test('getTrackedImages returns an empty list when the catalog file is missing', () => { From 9cf20d741d6cbed500a8864173197c3509274030 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 14:10:16 +0530 Subject: [PATCH 14/38] fix: make Onam translate/edit routing robust for the 3-message demo flow Supports the simplified Anjali flow (create -> add address -> translate to Malayalam) and fixes the case where "translate the banner to Malayalam" was rejected by the palette guardrail. - editLocalDesign: a Malayalam/translate request now short-circuits and maps straight to the Malayalam creative, bypassing the locked-field and palette guardrails (the model may place the translated text on any key, including one that looks like "background") - detect a translation request from Malayalam script or the word "Malayalam" in any edit key/value; mark language so resolveLocalImage picks the Malayalam image regardless of the edit key used - address-only edits still resolve to the final image - tests: cover address-only -> final, translate via unrecognized key, Malayalam under any key, and the background-key regression Co-Authored-By: Claude Opus 4.8 (1M context) --- data/onam-design.json | 6 ++--- src/actions.js | 53 +++++++++++++++++++++++++++++----------- src/createDesign.test.js | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/data/onam-design.json b/data/onam-design.json index 5cc39839c9..0ff590537e 100644 --- a/data/onam-design.json +++ b/data/onam-design.json @@ -1,9 +1,9 @@ { "_comment": "Template used by the create_design tool for brand-new (source: 'local') creatives. Swap the placeholder image URLs below with your hosted PNG URLs. See README / actions.js for how they map to edit state.", "images": { - "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base", - "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final", - "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam" + "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base?cache=off", + "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final?cache=off", + "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam?cache=off" }, "palette": [ { "name": "Marigold", "hex": "#F4A300" }, diff --git a/src/actions.js b/src/actions.js index 4fdbfb0db5..75d4398441 100644 --- a/src/actions.js +++ b/src/actions.js @@ -89,9 +89,14 @@ function hasMalayalam(value) { } // Pick the canned image for the current accumulated edit state. +function isMalayalamEdit(currentEdits) { + return Object.entries(currentEdits).some(([key, value]) => + hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) + ); +} + function resolveLocalImage(design, currentEdits) { - const values = Object.values(currentEdits); - if (values.some(hasMalayalam)) return design.images.malayalam; + if (isMalayalamEdit(currentEdits)) return design.images.malayalam; if (currentEdits.background || currentEdits.address) return design.images.final; return design.images.base; } @@ -107,24 +112,44 @@ function localEditElements(image) { async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { const design = image.design; const editableSlots = design.slots.editable; - const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); - if (unknown.length > 0) { - const editableNames = editableSlots.map((s) => s.name).join(', '); - return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + // Translation is special: the model may pass the Malayalam text (or the word + // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" + // request always maps to the Malayalam creative, regardless of the edit key. + const rawText = Object.entries(rawEdits || {}).flat().map(String); + const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); + + // A translation request short-circuits everything: map straight to the + // Malayalam creative. Never treat it as a field edit or run it through the + // palette / locked-field guardrails (the model may put the Malayalam text on + // any key, including one that looks like "background"). + let appliedEdits; + if (wantsMalayalam) { + appliedEdits = { language: 'Malayalam' }; + } else { + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + appliedEdits = edits; } - if ('background' in edits && !isPaletteColor(design, edits.background)) { - const options = design.palette.map((c) => c.name).join(' · '); - return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; - } - - recordEdits(phoneNumber, image.id, edits); - const currentEdits = { ...image.currentEdits, ...edits }; + recordEdits(phoneNumber, image.id, appliedEdits); + const currentEdits = { ...image.currentEdits, ...appliedEdits }; const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + const summary = wantsMalayalam + ? '• translated to Malayalam' + : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); try { await sendImage(phoneNumber, imageUrl); diff --git a/src/createDesign.test.js b/src/createDesign.test.js index da5adf0c54..5165ceff62 100644 --- a/src/createDesign.test.js +++ b/src/createDesign.test.js @@ -83,6 +83,50 @@ test('a Malayalam headline resolves to the Malayalam image', async () => { assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); +test('adding only a store address resolves to the final image (demo msg 2)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-a', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-a', 'local_1', { address: 'Princess Street, Kochi' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.final); +}); + +test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-b', { occasion: 'Onam' }, { sendImage }); + + // model phrases it as a language change rather than a headline edit + const reply = await actionEditGraphic('onam-phone-b', 'local_1', { language: 'Malayalam' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.doesNotMatch(reply, /locked by HQ/); +}); + +test('a Malayalam value under any key resolves to Malayalam', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-c', { occasion: 'Onam' }, { sendImage }); + + await actionEditGraphic('onam-phone-c', 'local_1', { banner: 'ഓണം ആശംസകൾ' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); +}); + +test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await actionCreateDesign('onam-phone-d', { occasion: 'Onam' }, { sendImage }); + + // model mistakenly puts the translated word on a "background"-like key + const reply = await actionEditGraphic('onam-phone-d', 'local_1', { background: 'ഓണം' }, { sendImage }); + + assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.doesNotMatch(reply, /approved palette/); +}); + test('editing a locked element on a created design is refused', async () => { useFixture(); const { sendImage } = captureSendImage(); From ea0f7659c83f8a4cd3b644a5257f0fe4f5f6dd6e Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:21:13 +0530 Subject: [PATCH 15/38] Feat/edit options quick reply buttons (#10) * Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 * Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 * Wire quick-reply button taps to the actual field/image being edited Button/list-row ids now encode edit:: instead of just a display title, so tapping an edit option tells GPT precisely which field to edit instead of a truncated, ambiguous label. GPT now asks for the missing value and applies it via edit_graphic once supplied, rather than never reaching the Express API. Co-Authored-By: Claude Sonnet 5 * Add design doc for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Add implementation plan for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Extract interactive-reply id parsing into a testable module * Support fully-specified multi-field edits in interactive reply ids * Add actionSelectTvModel handler for the TV product-swap quick replies * Wire select_tv_model GPT tool into the webhook handler --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- .../plans/2026-07-22-tv-product-swap.md | 494 ++++++++++++++++++ .../2026-07-22-tv-product-swap-design.md | 75 +++ src/actions.js | 21 +- src/actions.test.js | 31 +- src/app.js | 28 +- src/express/expressApi.js | 4 +- src/interactiveReply.js | 46 ++ src/interactiveReply.test.js | 51 ++ 8 files changed, 744 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-22-tv-product-swap.md create mode 100644 docs/superpowers/specs/2026-07-22-tv-product-swap-design.md create mode 100644 src/interactiveReply.js create mode 100644 src/interactiveReply.test.js diff --git a/docs/superpowers/plans/2026-07-22-tv-product-swap.md b/docs/superpowers/plans/2026-07-22-tv-product-swap.md new file mode 100644 index 0000000000..732e06911e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-tv-product-swap.md @@ -0,0 +1,494 @@ +# TV Product-Swap Quick-Reply Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a user asks to change the product in a graphic to a TV, show 3 quick-reply model buttons, and tapping one immediately applies `productImage`/`oldPrice`/`price` via the real Adobe Express edit pipeline — no follow-up question needed. + +**Architecture:** Extract the existing "interactive reply id" encode/parse logic out of `app.js` into a new small pure module (`src/interactiveReply.js`) so it's unit-testable and reusable, extend it to support fully-specified multi-field edits (JSON-encoded in the id, since the image URL contains a literal `=`), add a new `select_tv_model` GPT tool + `actionSelectTvModel` handler that reuses the existing `sendEditOptions` button-rendering path, and let the existing `edit_graphic` tool + `actionEditGraphic` validation handle the rest unchanged. + +**Tech Stack:** Node.js, `node:test` + `node:assert/strict` (existing test runner, no new dependencies), Express, OpenAI SDK (tool-calling), WhatsApp Cloud API. + +## Global Constraints + +- No new npm dependencies — use `node:test`/`node:assert/strict` exactly like every existing `*.test.js` file. +- `app.js` cannot be `require()`'d from a test file — it calls `app.listen(port)` at module load time with no test guard, so any logic that needs unit tests must live in a module that doesn't import `app.js`. +- All 3 TV model buttons apply the exact same fixed edits for now: `{ productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }` — not per-model values. +- Model titles, verbatim: `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- Question body text, verbatim: `Which model would you like to use?` +- Keep using the existing `edit_graphic`/`actionEditGraphic` validation path for applying the edit — do not duplicate or bypass its allowed-tag-name check. + +--- + +### Task 1: Extract interactive-reply id parsing into a testable module + +**Files:** +- Create: `src/interactiveReply.js` +- Create: `src/interactiveReply.test.js` +- Modify: `src/app.js:95-110` (remove the two functions being extracted), `src/app.js:1-9` (add require), `src/app.js:252-253` (use the imported function — no logic change) + +**Interfaces:** +- Produces: `parseEditOptionId(id: string) => { imageId: string, fieldName: string } | null` (same behavior as today), `messageTextForInteractiveReply(reply: { id: string, title: string }) => string` (same behavior as today). + +This task is a pure refactor — behavior must not change. It exists so Task 2 can extend this logic with real unit tests, since `app.js` itself can't be safely required in a test file (see Global Constraints). + +- [ ] **Step 1: Write the failing test file** + +Create `src/interactiveReply.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `Cannot find module './interactiveReply'` + +- [ ] **Step 3: Create the module by moving the existing logic out of app.js** + +Create `src/interactiveReply.js`: + +```js +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (see +// expressApi.buildEditOptions). Parse that back out so a tap can tell GPT exactly +// which image and field the user picked, instead of only the truncated button title. +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith('edit:')) return null; + const rest = id.slice('edit:'.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + return { imageId: rest.slice(0, separatorIndex), fieldName: rest.slice(separatorIndex + 1) }; +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { parseEditOptionId, messageTextForInteractiveReply }; +``` + +Now remove the two functions from `app.js:95-110` (the block starting with the `// Edit option button/list-row ids are...` comment and ending after the `messageTextForInteractiveReply` function), and instead require them at the top. Change `src/app.js:3-9` from: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +``` + +`app.js:252-253` (the webhook loop) is unchanged — it already just calls `messageTextForInteractiveReply(interactiveReply)`, which now resolves to the imported function instead of a local one. `parseEditOptionId` is imported for use in Task 4's wiring even though nothing in `app.js` calls it directly yet after this task. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (6 tests) + +- [ ] **Step 5: Run the full suite to confirm nothing else broke** + +Run: `node --test src/` +Expected: same pass/fail counts as before this task (32 pass, 2 pre-existing unrelated failures in `actions.test.js` — see Note below) + +> **Note:** `src/actions.test.js` has 2 pre-existing failing tests (`actionCheckAllowedEdits lists the tagged elements...` and `...shows the latest edited value...`) unrelated to this plan — they assert `reply` is a string, but `actionCheckAllowedEdits` has returned an object since an earlier commit. Do not fix them as part of this plan; just confirm the count doesn't change. + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js src/app.js +git commit -m "Extract interactive-reply id parsing into a testable module" +``` + +--- + +### Task 2: Support fully-specified multi-field edits in interactive reply ids + +**Files:** +- Modify: `src/interactiveReply.js` +- Modify: `src/interactiveReply.test.js` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `buildValueEditId(imageId: string, edits: object) => string`, `parseEditOptionId` now also returns `{ imageId: string, edits: object }` (no `fieldName`) when the id encodes a full edits object, `messageTextForInteractiveReply` now also handles that shape. Task 3 (`actionSelectTvModel`) calls `buildValueEditId`. Task 4 (`app.js` webhook wiring) relies on `parseEditOptionId`/`messageTextForInteractiveReply` handling both shapes transparently — no change needed in `app.js` for this task. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/interactiveReply.test.js`: + +```js +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); +``` + +(Update the existing `require` at the top of the test file to include `buildValueEditId` in the destructure — one `require` line covering all four exports is fine.) + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `node --test src/interactiveReply.test.js` +Expected: FAIL — `buildValueEditId is not a function` (and the round-trip/message tests fail too) + +- [ ] **Step 3: Implement the extension** + +Replace the contents of `src/interactiveReply.js` with: + +```js +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.buildTvModelOptions) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/interactiveReply.test.js` +Expected: PASS (9 tests) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: same as Task 1's Step 5 (no new failures) + +- [ ] **Step 6: Commit** + +```bash +git add src/interactiveReply.js src/interactiveReply.test.js +git commit -m "Support fully-specified multi-field edits in interactive reply ids" +``` + +--- + +### Task 3: Add the TV model options handler + +**Files:** +- Modify: `src/actions.js` +- Modify: `src/actions.test.js` + +**Interfaces:** +- Consumes: `buildValueEditId(imageId, edits)` from `./interactiveReply` (Task 2). +- Produces: `actionSelectTvModel(imageId) => { type: 'edit_options', bodyText: string, options: Array<{ id: string, title: string }> }` (synchronous — no Express API call needed to build the fixed 3-option list). Task 4 (`app.js` webhook wiring) calls this and passes the result straight into the existing `sendEditOptions(phoneNumber, result)` — same shape `actionCheckAllowedEdits` already produces. + +- [ ] **Step 1: Write the failing tests** + +Add to `src/actions.test.js` (add `actionSelectTvModel` to the existing `require('./actions')` destructure, and add `parseEditOptionId` from `./interactiveReply` for assertions): + +```js +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); +const { parseEditOptionId } = require('./interactiveReply'); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test src/actions.test.js` +Expected: FAIL — `actionSelectTvModel is not a function` + +- [ ] **Step 3: Implement `actionSelectTvModel`** + +In `src/actions.js`, add near the top (after the existing `require`s at `src/actions.js:1-2`): + +```js +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +Then add the function itself (near `actionCheckAllowedEdits`, e.g. directly after it): + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} +``` + +Finally, add `actionSelectTvModel` to `module.exports` at the bottom of `src/actions.js`: + +```js +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test src/actions.test.js` +Expected: PASS for the 2 new tests (the 2 pre-existing unrelated failures from Task 1's Note still fail — that's expected, don't touch them) + +- [ ] **Step 5: Run the full suite** + +Run: `node --test src/` +Expected: 34 pass, 2 pre-existing unrelated failures (same 2 as before — count of passing tests increases by 2 vs. Task 2's baseline) + +- [ ] **Step 6: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Add actionSelectTvModel handler for the TV product-swap quick replies" +``` + +--- + +### Task 4: Wire the `select_tv_model` GPT tool into the webhook + +**Files:** +- Modify: `src/app.js` + +**Interfaces:** +- Consumes: `actionSelectTvModel(imageId)` from `./actions` (Task 3). +- Produces: nothing new for later tasks — this is the last piece of wiring. + +- [ ] **Step 1: Import `actionSelectTvModel`** + +In `src/app.js:4-9`, change: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +to: + +```js +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +} = require('./actions'); +``` + +- [ ] **Step 2: Add the `select_tv_model` tool definition** + +In `src/app.js`, in the `tools` array (currently `src/app.js:114-184`), add a new entry. Insert it right after the `check_allowed_edits` tool definition (ends at `src/app.js:151`) and before `edit_graphic`: + +```js + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +- [ ] **Step 3: Handle the tool call in the webhook switch statement** + +In `src/app.js`, the `switch (action)` block (currently `src/app.js:272-307`) has a `case 'check_allowed_edits':` block (`src/app.js:282-293`) that shows the pattern to follow — sending a placeholder text, calling the action, then rendering options via `sendEditOptions` and setting `skipSend = true`. Add a new case right after it: + +```js + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } +``` + +(No "⏳ ..." placeholder text before this one — unlike `check_allowed_edits`/`edit_graphic`, it's synchronous and doesn't call the Express API, so there's nothing to wait on.) + +- [ ] **Step 4: Run the full test suite** + +Run: `node --test src/` +Expected: same pass/fail counts as Task 3's Step 5 (this task only touches `app.js`, which has no automated tests — see Global Constraints) + +- [ ] **Step 5: Manual verification via a local script** + +`app.js` can't be imported in a test, so verify the new wiring by exercising the pieces it composes directly in a scratch script. Run this with `node`: + +```js +const { actionSelectTvModel, actionEditGraphic } = require('./src/actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./src/interactiveReply'); + +// 1. Simulate GPT calling select_tv_model('img_1') after "change product to tv": +const result = actionSelectTvModel('img_1'); +console.log('Options shown to user:', result.options); + +// 2. Simulate the user tapping the first button — this is what the webhook receives: +const tappedReply = { id: result.options[0].id, title: result.options[0].title }; + +// 3. Simulate app.js turning that tap into a synthetic message for GPT: +console.log('Synthetic message sent to GPT:', messageTextForInteractiveReply(tappedReply)); + +// 4. Confirm parseEditOptionId recovers the exact edits object actionEditGraphic will receive: +console.log('Parsed edits:', parseEditOptionId(tappedReply.id)); +``` + +Expected output: +- `Options shown to user` has 3 entries titled `Sony Bravia K-75`, `LG UA82 AI`, `Samsung UA4`. +- `Synthetic message sent to GPT` reads: `I'd like to change "productImage" to "https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000", "oldPrice" to "33999", "price" to "27199" on image img_1.` +- `Parsed edits` is `{ imageId: 'img_1', edits: { productImage: '...', oldPrice: 33999, price: 27199 } }`. + +This confirms the full chain (options → tap → synthetic GPT message → recovered edits) works end to end without needing a live WhatsApp/OpenAI/Express connection. The remaining link — GPT actually calling `edit_graphic` with this synthetic message, and `actionEditGraphic` calling the real Express API — is already covered by the existing `edit_graphic` system-prompt rule and `actions.test.js` coverage; it doesn't need TV-specific testing since nothing about it is TV-specific. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js +git commit -m "Wire select_tv_model GPT tool into the webhook handler" +``` + +--- + +## Post-plan check + +After Task 4, run `node --test src/` one final time and confirm: the only failures are the 2 pre-existing ones already present before this plan started (`actionCheckAllowedEdits lists the tagged elements for a known image` and `actionCheckAllowedEdits shows the latest edited value instead of the stale original document value`). If any other test fails, stop and investigate before considering this plan done. diff --git a/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md new file mode 100644 index 0000000000..7ecbc2fc86 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-tv-product-swap-design.md @@ -0,0 +1,75 @@ +# Design: "Change product to TV" quick-reply flow + +## Goal + +Support this WhatsApp conversation flow: + +1. User asks to change the product in a graphic to a TV (e.g. "change product to tv"), without naming a specific model. +2. System asks "Which model would you like to use?" with 3 tappable quick-reply buttons: Sony Bravia K-75 / LG UA82 AI / Samsung UA4. +3. User taps one. +4. System immediately calls the Adobe Express API to apply all of: `productImage` → the TV's image, `oldPrice` → 33999, `price` → 27199. No further clarifying question — the tap alone is enough to act. +5. System sends the updated image, same as any other edit. + +Scope is TV-only for now: one hardcoded trigger, one hardcoded list of 3 models, and (until real per-model assets exist) the same placeholder image URL and the same fixed `oldPrice`/`price` for all three buttons. Not designed as a general "product category" system — if a second category is needed later, this gets generalized then. + +## New GPT tool + +Added to the existing `tools` array in `app.js`, following the same shape as `check_allowed_edits`/`edit_graphic`: + +```js +select_tv_model({ image_id }) +``` + +Description: "Use when the user asks to change/set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields — use edit_graphic for those." `image_id` is picked from the tracked-images list exactly like the other tools. + +## Action handler + +New `actionSelectTvModel(phoneNumber, imageId)` in `actions.js`, returning the same `{ type: 'edit_options', bodyText, options }` shape `actionCheckAllowedEdits` already returns, so it reuses `sendEditOptions` unchanged (3 options → one native button message, no "More edits" chunking). + +```js +const TV_MODELS = [ + { title: 'Sony Bravia K-75' }, + { title: 'LG UA82 AI' }, + { title: 'Samsung UA4' }, +]; +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; +``` + +`bodyText`: `"Which model would you like to use?"`. Each option's `id` encodes the *full* edits object (not just a field name), since the tap must fully specify the change — see below. + +No `findTrackedImage`/Express lookup is needed at this step (unlike `check_allowed_edits`, which reads the real tagged document to build its option list); the 3 buttons are fixed regardless of the document. Validation still happens later, for free, in `actionEditGraphic`. + +## Button `id` encoding (extends the existing mechanism) + +Today, edit-option button ids are `edit::` (bare field, no value — tapping always leads to a clarifying question, per `app.js` `parseEditOptionId`/`messageTextForInteractiveReply`). + +The TV buttons need to carry a *value* too, and here it's actually multiple field/value pairs at once. A naive `field=value` suffix breaks because the image URL itself contains a literal `=` (`?wid=1000`), and there's more than one field. Instead, the remainder after `edit::` becomes a JSON-encoded edits object when a value is already known: + +``` +edit:: +``` + +`parseEditOptionId` is extended: after splitting off `imageId`, try `JSON.parse(decodeURIComponent(remainder))`. If it parses to a plain object, treat this as a **fully-specified edit** (`{ imageId, edits }`). If parsing throws, fall back to the existing behavior — remainder is a bare field name (`{ imageId, fieldName }`). + +`messageTextForInteractiveReply` is extended to match: +- Fully-specified (`edits` present): build a synthetic user message listing every field/value, e.g. `I'd like to change "productImage" to "https://...", "oldPrice" to "33999", "price" to "27199" on image img_1.` GPT already has a system-prompt rule that a fully-specified request should go straight to `edit_graphic` — no new prompt change needed here. +- Bare field (existing case): unchanged — asks GPT to prompt for the missing value. + +This keeps one general mechanism for "a button tap that already knows its value(s)" rather than adding TV-specific parsing. + +## Validation / error handling + +Unchanged and reused: when `edit_graphic` runs, `actionEditGraphic` already checks every requested key against the real tagged document's element names and rejects unknown ones with the existing friendly message. If `productImage`/`oldPrice`/`price` aren't real tags on a given document, the user gets that existing rejection message — no new error handling to write. + +## Testing + +- Unit test for the extended `parseEditOptionId`/`messageTextForInteractiveReply`: bare-field id still parses as before; a JSON-encoded multi-field id parses to `{ imageId, edits }` and produces a message listing all fields/values. +- Unit test for `actionSelectTvModel`: returns 3 options, each titled with a model name, each `id` decodable back to `{ productImage, oldPrice, price }` with the expected values. +- Manual/simulated webhook check: "change product to tv" → 3 buttons in one message → tap one → `edit_graphic` called with all 3 fields → thumbnail sent. + +## Out of scope + +- Per-model images or pricing (all 3 buttons currently share the same values). +- Any product category other than TV. +- A data-driven/config-file catalog of product categories — revisit if a second category is actually needed. diff --git a/src/actions.js b/src/actions.js index 4fdbfb0db5..42ffa98ef4 100644 --- a/src/actions.js +++ b/src/actions.js @@ -2,6 +2,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); const expressApi = require('./express/expressApi'); +const { buildValueEditId } = require('./interactiveReply'); + +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; function loadOnamDesign() { const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); @@ -148,7 +153,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return { type: 'edit_options', bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elements), + options: expressApi.buildEditOptions(elements, imageId), historyText: expressApi.formatAllowedEdits(image.name, elements), }; } @@ -160,7 +165,7 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { return { type: 'edit_options', bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits), + options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), }; } catch (err) { @@ -169,6 +174,17 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } } +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} + // Routes to the right edit path based on where the design came from: // source: 'local' → canned-image design (Flow 2.2), no Express calls // source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) @@ -255,4 +271,5 @@ module.exports = { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, }; diff --git a/src/actions.test.js b/src/actions.test.js index 1b16b26ced..43e481e204 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -3,9 +3,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); const expressApi = require('./express/expressApi'); const { findTrackedImage, recordEdits } = require('./imageStore'); +const { parseEditOptionId } = require('./interactiveReply'); function writeFixtureCatalog(entries) { const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -147,3 +148,31 @@ test('actionEditGraphic tells the user delivery failed but keeps the recorded ed const image = findTrackedImage('phone-7', 'img_1'); assert.deepEqual(image.currentEdits, { cta: '20% off' }); }); + +test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = actionSelectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); diff --git a/src/app.js b/src/app.js index fe965dcff9..276d602d3d 100644 --- a/src/app.js +++ b/src/app.js @@ -7,7 +7,9 @@ const { actionCheckAllowedEdits, actionEditGraphic, actionGenerateBulkGraphics, + actionSelectTvModel, } = require('./actions'); +const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); const app = express(); app.use(express.json()); @@ -154,6 +156,21 @@ const tools = [ }, }, }, + { + type: 'function', + function: { + name: 'select_tv_model', + description: + 'Use when the user asks to change or set the product in a graphic to a TV, without specifying which model. Do not use this for edits to text fields or other product types — use edit_graphic for those.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, { type: 'function', function: { @@ -202,6 +219,7 @@ async function decideAction(phoneNumber, userMessage) { Analyze the user's message and conversation history, then call the appropriate tool. Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. +If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. @@ -257,7 +275,7 @@ app.post('/', async (req, res) => { if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); const interactiveReply = message?.interactive?.button_reply || message?.interactive?.list_reply; - const userText = message?.text?.body || interactiveReply?.title; + const userText = message?.text?.body || (interactiveReply && messageTextForInteractiveReply(interactiveReply)); if (!userText) continue; const phoneNumber = message.from; @@ -305,6 +323,14 @@ app.post('/', async (req, res) => { break; } + case 'select_tv_model': { + const result = actionSelectTvModel(args.image_id); + await sendEditOptions(phoneNumber, result); + replyText = result.bodyText; + skipSend = true; + break; + } + case 'edit_graphic': await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); diff --git a/src/express/expressApi.js b/src/express/expressApi.js index cb7ff9a857..9cc154df9c 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -99,9 +99,9 @@ function truncateTitle(title, maxLength = 20) { return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } -function buildEditOptions(elements) { +function buildEditOptions(elements, imageId) { return elements.map((element) => ({ - id: `edit:${element.name}`, + id: `edit:${imageId}:${element.name}`, title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), })); } diff --git a/src/interactiveReply.js b/src/interactiveReply.js new file mode 100644 index 0000000000..d7aa55a63f --- /dev/null +++ b/src/interactiveReply.js @@ -0,0 +1,46 @@ +const EDIT_ID_PREFIX = 'edit:'; + +// Edit option button/list-row ids are `edit:${imageId}:${fieldName}` (bare field — +// see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` +// (fully-specified — see actions.actionSelectTvModel) — parse either shape back out so +// a tap can tell GPT exactly what to do instead of only the truncated button title. +function buildValueEditId(imageId, edits) { + return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +} + +function parseEditOptionId(id) { + if (typeof id !== 'string' || !id.startsWith(EDIT_ID_PREFIX)) return null; + const rest = id.slice(EDIT_ID_PREFIX.length); + const separatorIndex = rest.indexOf(':'); + if (separatorIndex === -1) return null; + const imageId = rest.slice(0, separatorIndex); + const remainder = rest.slice(separatorIndex + 1); + + try { + const decoded = JSON.parse(decodeURIComponent(remainder)); + if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { + return { imageId, edits: decoded }; + } + } catch { + // Not JSON — remainder is a bare field name, handled below. + } + + return { imageId, fieldName: remainder }; +} + +function describeEdits(edits) { + return Object.entries(edits) + .map(([key, value]) => `"${key}" to "${value}"`) + .join(', '); +} + +function messageTextForInteractiveReply(reply) { + const parsed = parseEditOptionId(reply.id); + if (!parsed) return reply.title; + if (parsed.edits) { + return `I'd like to change ${describeEdits(parsed.edits)} on image ${parsed.imageId}.`; + } + return `I'd like to change "${parsed.fieldName}" on image ${parsed.imageId}.`; +} + +module.exports = { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply }; diff --git a/src/interactiveReply.test.js b/src/interactiveReply.test.js new file mode 100644 index 0000000000..04a62c7e6b --- /dev/null +++ b/src/interactiveReply.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildValueEditId, parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); + +test('parseEditOptionId parses a bare field-only id', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('parseEditOptionId returns null for an id with no edit: prefix', () => { + assert.equal(parseEditOptionId('something_else'), null); +}); + +test('parseEditOptionId returns null for an id missing the field separator', () => { + assert.equal(parseEditOptionId('edit:img_1'), null); +}); + +test('parseEditOptionId returns null for a non-string id', () => { + assert.equal(parseEditOptionId(undefined), null); +}); + +test('messageTextForInteractiveReply builds a change-field message for a bare field id', () => { + const text = messageTextForInteractiveReply({ id: 'edit:img_1:heading', title: 'Change heading' }); + assert.equal(text, 'I\'d like to change "heading" on image img_1.'); +}); + +test('messageTextForInteractiveReply falls back to the title when the id is unparseable', () => { + const text = messageTextForInteractiveReply({ id: 'not-an-edit-id', title: 'Some Title' }); + assert.equal(text, 'Some Title'); +}); + +test('buildValueEditId round-trips through parseEditOptionId', () => { + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const id = buildValueEditId('img_1', edits); + const parsed = parseEditOptionId(id); + assert.deepEqual(parsed, { imageId: 'img_1', edits }); +}); + +test('parseEditOptionId still parses a bare field-only id after the value-edit change', () => { + const parsed = parseEditOptionId('edit:img_1:heading'); + assert.deepEqual(parsed, { imageId: 'img_1', fieldName: 'heading' }); +}); + +test('messageTextForInteractiveReply lists every field/value for a fully-specified edit id', () => { + const id = buildValueEditId('img_1', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const text = messageTextForInteractiveReply({ id, title: 'Sony Bravia K-75' }); + assert.equal( + text, + 'I\'d like to change "productImage" to "https://example.com/tv.png", "oldPrice" to "33999", "price" to "27199" on image img_1.' + ); +}); From 2d3272ebf939f1e321df2347215fe0bc946550d5 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:06:08 +0530 Subject: [PATCH 16/38] refactor: split the two edit flows into self-contained modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two flows now live in their own files so changing one cannot affect the other; actions.js is a thin router that dispatches deterministically on image.source. No behavior change (all tests pass). - expressFlow.js: Flow 1 (source: 'express') — Adobe Express catalog designs (checkAllowedEdits, editGraphic, selectTvModel, discount cap) - localFlow.js: Flow 2 (source: 'local') — canned-image designs (createDesign, checkAllowedEdits, editGraphic, palette + Malayalam handling) - editOptions.js: shared pure presentation helpers (buildEditOptions, formatAllowedEdits), extracted so neither flow depends on the other; expressApi re-exports them for backward compatibility - actions.js: thin dispatcher only (resolve image → route by source) - tests mirror the modules: expressFlow.test.js + localFlow.test.js, with a small actions.test.js for router/dispatch behavior - docs/diagrams: mermaid flowcharts for both flows Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diagrams/flow-express-catalog-edit.mmd | 26 ++ docs/diagrams/flow-local-create-design.mmd | 24 ++ src/actions.js | 281 ++---------------- src/actions.test.js | 174 ++--------- src/editOptions.js | 42 +++ src/express/expressApi.js | 40 +-- src/expressFlow.js | 125 ++++++++ src/expressFlow.test.js | 177 +++++++++++ src/localFlow.js | 150 ++++++++++ ...createDesign.test.js => localFlow.test.js} | 85 +++--- 10 files changed, 639 insertions(+), 485 deletions(-) create mode 100644 docs/diagrams/flow-express-catalog-edit.mmd create mode 100644 docs/diagrams/flow-local-create-design.mmd create mode 100644 src/editOptions.js create mode 100644 src/expressFlow.js create mode 100644 src/expressFlow.test.js create mode 100644 src/localFlow.js rename src/{createDesign.test.js => localFlow.test.js} (51%) diff --git a/docs/diagrams/flow-express-catalog-edit.mmd b/docs/diagrams/flow-express-catalog-edit.mmd new file mode 100644 index 0000000000..644ca94a8c --- /dev/null +++ b/docs/diagrams/flow-express-catalog-edit.mmd @@ -0,0 +1,26 @@ +%% Flow A — Rajesh: edit an existing catalog design (image.source = "express") +%% Backed by the real Adobe Express API, with interactive quick-reply buttons. +%% Render: https://mermaid.live or any Mermaid-capable viewer. +flowchart TD + R1["User: 'what can I change?'"] --> CAE["tool: check_allowed_edits"] + CAE --> CAEa["actionCheckAllowedEdits
image.source = express"] + CAEa --> EX1["expressApi.getTaggedDocument(docId)
collectTaggedElements"] + EX1 --> BTN["buildEditOptions
returns {edit_options, historyText}"] + BTN --> SEND1["sendEditOptions() → reply buttons
id = 'edit:img_1:heading'"] + + SEND1 -.->|"user taps a button"| TAP["webhook: messageTextForInteractiveReply()
parse id → I'd like to change heading on image img_1"] + TAP --> ASK["tool: ask_for_more_information
what would you like to change it to?"] + ASK -.->|"user replies with a value"| EG + + R2["User: 'change the offer to 7500'"] --> EG["tool: edit_graphic"] + EG --> RT{"actionEditGraphic
image.source?"} + RT -->|"express"| EXE["editExpressDesign"] + EXE --> V{"field is a tagged element?
discount ≤ MAX_DISCOUNT?"} + V -->|"ok"| GEN["expressApi.generateVariation
→ pollJobStatus → thumbnailUrl"] + GEN --> REC["recordEdits + sendImage(thumbnail)"] + V -->|"no"| REJ["reply: can't edit that field / discount capped"] + + R3["User: 'change the product to a TV'"] --> STV["tool: select_tv_model"] + STV --> STVa["actionSelectTvModel → 3 model buttons
id encodes full edits (buildValueEditId)"] + STVa -.->|"user taps e.g. LG UA82 AI"| TAP2["parse id → edits JSON → sentence → GPT"] + TAP2 --> EG diff --git a/docs/diagrams/flow-local-create-design.mmd b/docs/diagrams/flow-local-create-design.mmd new file mode 100644 index 0000000000..f24b40931f --- /dev/null +++ b/docs/diagrams/flow-local-create-design.mmd @@ -0,0 +1,24 @@ +%% Flow B — Anjali: create + edit a brand-new design (image.source = "local") +%% No Express calls — resolves to canned image URLs in data/onam-design.json. +%% Render: https://mermaid.live or any Mermaid-capable viewer. +flowchart TD + A1["User: 'create a new design for Onam,
LG washing machine + dishwasher, 20% off'"] --> CD["tool: create_design"] + CD --> CDa["actionCreateDesign
loadOnamDesign + imageStore.createDesign
(source = local, id = local_1)"] + CDa --> S1["sendImage(images.base)"] + + A2["User: 'add my store address Princess Street Kochi'"] --> EG2["tool: edit_graphic"] + A3["User: 'translate the banner to Malayalam'"] --> EG3["tool: edit_graphic"] + EG2 --> RT{"actionEditGraphic
image.source?"} + EG3 --> RT + RT -->|"local"| EL["editLocalDesign"] + + EL --> M{"Malayalam request?
(Malayalam script OR the word
'Malayalam' in ANY key/value)"} + M -->|"yes"| MAL["short-circuit:
appliedEdits = {language: 'Malayalam'}
skip locked/palette guards"] + M -->|"no"| CANON["canonicalizeEdits (aliases)"] + CANON --> GUARD{"unknown key? → 'locked by HQ'
background off-palette? → reject"} + GUARD -->|"ok"| RES["resolveLocalImage:
address/background set → images.final
else → images.base"] + MAL --> RESM["resolveLocalImage → images.malayalam"] + RES --> S2["recordEdits + sendImage(url)"] + RESM --> S2 + + RT -->|"express"| EXE["editExpressDesign
(Adobe Express API — see other diagram)"] diff --git a/src/actions.js b/src/actions.js index c79181a242..90662359b3 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,17 +1,13 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { getTrackedImages, findTrackedImage, recordEdits, createDesign } = require('./imageStore'); -const expressApi = require('./express/expressApi'); -const { buildValueEditId } = require('./interactiveReply'); +// Thin action layer for the GPT tools. The two edit flows live in their own +// self-contained modules and are dispatched deterministically by image.source: +// source: 'express' → expressFlow (real Adobe Express API — catalog designs) +// source: 'local' → localFlow (canned image URLs — runtime-created designs) +// Keeping this file free of flow-specific logic means changing one flow can never +// affect the other. -const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; -const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; -const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; - -function loadOnamDesign() { - const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const expressFlow = require('./expressFlow'); +const localFlow = require('./localFlow'); function formatUnknownImageMessage(phoneNumber) { const images = getTrackedImages(phoneNumber); @@ -25,263 +21,36 @@ async function actionListCampaignGraphics() { return 'Graphics in your current campaign:\n1. Croma Earbuds'; } -// Create a brand-new creative from a text description (Flow 2.2 — Onam). -// Unlike the catalog designs, this has no Adobe Express document; it is a -// `source: 'local'` design that resolves to canned images (see data/onam-design.json). -async function actionCreateDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { - const design = loadOnamDesign(); - const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); - const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); - console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); - createDesign(phoneNumber, { name, design }); - - try { - await sendImage(phoneNumber, design.images.base); - } catch (err) { - console.error('[actionCreateDesign] sendImage error', { message: err.message }); - return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; - } - - return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; -} - -function withCurrentEdits(elements, currentEdits) { - return elements.map((element) => - element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element - ); -} - -const MAX_DISCOUNT_PERCENT = 40; - -function isDiscountField(name) { - return /discount/i.test(name); -} - -function parsePercent(value) { - const match = String(value).match(/(\d+(?:\.\d+)?)/); - return match ? Number(match[1]) : null; -} - -// ── Local (canned-image) design helpers — Flow 2.2 ─────────────────────────── - -function normalizeKey(key) { - return String(key).toLowerCase().trim().replace(/\s+/g, '_'); -} - -// Map GPT's free-form edit keys onto the design's canonical slot names via -// aliases, so "background_color" / "colour" / "heading" all resolve correctly. -function canonicalizeEdits(editableSlots, edits) { - const canonical = {}; - const unknown = []; - for (const [rawKey, value] of Object.entries(edits || {})) { - const key = normalizeKey(rawKey); - const slot = editableSlots.find( - (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) - ); - if (slot) canonical[slot.name] = value; - else unknown.push(rawKey); - } - return { canonical, unknown }; -} - -function isPaletteColor(design, value) { - const v = String(value).trim().toLowerCase(); - return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); -} - -function hasMalayalam(value) { - return /[ഀ-ൿ]/.test(String(value)); -} - -// Pick the canned image for the current accumulated edit state. -function isMalayalamEdit(currentEdits) { - return Object.entries(currentEdits).some(([key, value]) => - hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) - ); -} - -function resolveLocalImage(design, currentEdits) { - if (isMalayalamEdit(currentEdits)) return design.images.malayalam; - if (currentEdits.background || currentEdits.address) return design.images.final; - return design.images.base; -} - -function localEditElements(image) { - return image.design.slots.editable.map((slot) => ({ - name: slot.name, - type: slot.type || 'text', - value: image.currentEdits[slot.name] ?? '', - })); +// Flow 2 (local/canned) — create a brand-new design from a text description. +function actionCreateDesign(phoneNumber, args, ctx) { + return localFlow.createDesign(phoneNumber, args, ctx); } -async function editLocalDesign(phoneNumber, image, rawEdits, { sendImage }) { - const design = image.design; - const editableSlots = design.slots.editable; - - // Translation is special: the model may pass the Malayalam text (or the word - // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" - // request always maps to the Malayalam creative, regardless of the edit key. - const rawText = Object.entries(rawEdits || {}).flat().map(String); - const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); - - // A translation request short-circuits everything: map straight to the - // Malayalam creative. Never treat it as a field edit or run it through the - // palette / locked-field guardrails (the model may put the Malayalam text on - // any key, including one that looks like "background"). - let appliedEdits; - if (wantsMalayalam) { - appliedEdits = { language: 'Malayalam' }; - } else { - const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); - - if (unknown.length > 0) { - const editableNames = editableSlots.map((s) => s.name).join(', '); - return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; - } - - if ('background' in edits && !isPaletteColor(design, edits.background)) { - const options = design.palette.map((c) => c.name).join(' · '); - return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; - } - - appliedEdits = edits; - } - - recordEdits(phoneNumber, image.id, appliedEdits); - const currentEdits = { ...image.currentEdits, ...appliedEdits }; - const imageUrl = resolveLocalImage(design, currentEdits); - console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - - const summary = wantsMalayalam - ? '• translated to Malayalam' - : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - - try { - await sendImage(phoneNumber, imageUrl); - } catch (err) { - console.error('[editLocalDesign] sendImage error', { imageId: image.id, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; - } - - return `Updated "${image.name}":\n${summary}`; +// Flow 1 (express/catalog) — offer the fixed TV models as quick replies. +function actionSelectTvModel(imageId) { + return expressFlow.selectTvModel(imageId); } +// Router: resolve the image, then hand off to the flow that owns it. async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - if (image.source === 'local') { - const elements = localEditElements(image); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elements, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elements), - }; - } - - try { - const doc = await expressApi.getTaggedDocument(image.docId); - const elements = expressApi.collectTaggedElements(doc); - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: expressApi.buildEditOptions(elementsWithCurrentEdits, imageId), - historyText: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), - }; - } catch (err) { - console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; - } -} + if (!image) return formatUnknownImageMessage(phoneNumber); -function actionSelectTvModel(imageId) { - return { - type: 'edit_options', - bodyText: 'Which model would you like to use?', - options: TV_MODEL_TITLES.map((title) => ({ - id: buildValueEditId(imageId, TV_MODEL_EDITS), - title, - })), - }; + return image.source === 'local' + ? localFlow.checkAllowedEdits(image) + : expressFlow.checkAllowedEdits(image); } -// Routes to the right edit path based on where the design came from: -// source: 'local' → canned-image design (Flow 2.2), no Express calls -// source: 'express' → real Adobe Express-backed catalog design (Flow 2.1) +// Router: resolve the image, then hand off to the flow that owns it. async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); - if (!image) { - return formatUnknownImageMessage(phoneNumber); - } - - if (image.source === 'local') { - return editLocalDesign(phoneNumber, image, edits, { sendImage }); - } - return editExpressDesign(phoneNumber, image, edits, { sendImage }); -} - -async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { - let elements; - try { - const doc = await expressApi.getTaggedDocument(image.docId); - elements = expressApi.collectTaggedElements(doc); - } catch (err) { - console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; - } - - const allowedNames = elements.map((element) => element.name); - const requestedKeys = Object.keys(edits || {}); - const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); - - if (disallowedKeys.length > 0) { - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; - } - - const oversizedDiscountKeys = requestedKeys.filter((key) => { - if (!isDiscountField(key)) return false; - const percent = parsePercent(edits[key]); - return percent !== null && percent > MAX_DISCOUNT_PERCENT; - }); - - if (oversizedDiscountKeys.length > 0) { - return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; - } - - const mergedEdits = { ...image.currentEdits, ...edits }; - const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); - const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); - - let thumbnailUrl; - try { - const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); - const result = await expressApi.pollJobStatus(statusUrl); - thumbnailUrl = result.document.thumbnailUrl; - console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); - } catch (err) { - console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); - return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; - } - - recordEdits(phoneNumber, image.id, edits); - - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - - try { - await sendImage(phoneNumber, thumbnailUrl); - } catch (err) { - console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; - } + if (!image) return formatUnknownImageMessage(phoneNumber); - return `Updated "${image.name}":\n${summary}`; + return image.source === 'local' + ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage }) + : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage }); } async function actionGenerateBulkGraphics(filename) { diff --git a/src/actions.test.js b/src/actions.test.js index 43e481e204..2f0603f92b 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -1,12 +1,12 @@ +// Router-level tests for actions.js — image resolution, not-found handling, and +// deterministic dispatch by image.source. Flow behavior itself is covered in +// expressFlow.test.js and localFlow.test.js. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel } = require('./actions'); -const expressApi = require('./express/expressApi'); -const { findTrackedImage, recordEdits } = require('./imageStore'); -const { parseEditOptionId } = require('./interactiveReply'); +const { actionCreateDesign, actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); function writeFixtureCatalog(entries) { const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -14,165 +14,41 @@ function writeFixtureCatalog(entries) { process.env.EXPRESS_TEMPLATES_FILE = fixturePath; } -const SAMPLE_ELEMENTS_DOC = { - documentPages: [ - { - pageNumber: 1, - taggedElements: [ - { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, - { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, - ], - }, - ], -}; - -test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async (docId) => { - assert.equal(docId, 'urn:doc:1'); - return SAMPLE_ELEMENTS_DOC; +function writeOnamFixture() { + const onam = { + images: { base: 'https://cdn.test/b.png', final: 'https://cdn.test/f.png', malayalam: 'https://cdn.test/m.png' }, + palette: [{ name: 'Marigold', hex: '#F4A300' }], + slots: { editable: [{ name: 'address', type: 'text', aliases: [] }], locked: [] }, }; - - const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); - - assert.match(reply.historyText, /Croma Earbuds/); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply.historyText, /cta: currently/); -}); - -test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - recordEdits('phone-1b', 'img_1', { cta: '20% off' }); - - const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); - - assert.match(reply.historyText, /cta: currently "20% off"/); - assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); -}); + const p = path.join(os.tmpdir(), `onam-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(onam)); + process.env.ONAM_DESIGN_FILE = p; +} test('actionCheckAllowedEdits reports unknown images without throwing', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + const reply = await actionCheckAllowedEdits('phone-r1', 'img_nope'); assert.match(reply, /couldn't find that image/); }); -test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => { - throw new Error('getTaggedDocument failed 500: boom'); - }; - - const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); - - assert.match(reply, /couldn't check the allowed edits/); -}); - -test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => { - throw new Error('should not be called'); - }; - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; - - const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); - - assert.match(reply, /can't edit background_color/); - assert.equal(sendImageCalled, false); -}); - -test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { - assert.equal(docId, 'urn:doc:1'); - assert.deepEqual(tagMappings, { cta: '20% off' }); - assert.equal(pages, '1'); - assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); - return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; - }; - expressApi.pollJobStatus = async (statusUrl) => { - assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); - return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; - }; - - const sentCalls = []; - const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; - - const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /Updated "Croma Earbuds"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].to, 'phone-5'); - assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); - - const image = findTrackedImage('phone-5', 'img_1'); - assert.deepEqual(image.currentEdits, { cta: '20% off' }); -}); - -test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; - - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; - - const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /something went wrong generating/); - assert.equal(sendImageCalled, false); - - const image = findTrackedImage('phone-6', 'img_1'); - assert.deepEqual(image.currentEdits, {}); -}); - -test('actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { +test('actionEditGraphic reports unknown images without throwing', async () => { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); - expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); - const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + const reply = await actionEditGraphic('phone-r2', 'img_nope', { x: 'y' }, { sendImage: async () => {} }); - const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); - - assert.match(reply, /couldn't send the image right now/); - assert.doesNotMatch(reply, /something went wrong generating/); - - const image = findTrackedImage('phone-7', 'img_1'); - assert.deepEqual(image.currentEdits, { cta: '20% off' }); + assert.match(reply, /couldn't find that image/); }); -test('actionSelectTvModel returns the 3 fixed TV model options with the question body text', () => { - const result = actionSelectTvModel('img_1'); - - assert.equal(result.type, 'edit_options'); - assert.equal(result.bodyText, 'Which model would you like to use?'); - assert.equal(result.options.length, 3); - assert.deepEqual( - result.options.map((option) => option.title), - ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] - ); -}); +test('routes a local-source image to the canned flow (no Express API call)', async () => { + writeOnamFixture(); + const sent = []; + const sendImage = async (_to, link) => sent.push(link); + await actionCreateDesign('phone-r3', { occasion: 'Onam' }, { sendImage }); -test('actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { - const result = actionSelectTvModel('img_1'); + const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - for (const option of result.options) { - const parsed = parseEditOptionId(option.id); - assert.deepEqual(parsed, { - imageId: 'img_1', - edits: { - productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', - oldPrice: 33999, - price: 27199, - }, - }); - } + assert.match(reply, /Updated/); + assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/editOptions.js b/src/editOptions.js new file mode 100644 index 0000000000..6f994272f5 --- /dev/null +++ b/src/editOptions.js @@ -0,0 +1,42 @@ +// Shared presentation helpers for the "allowed edits" summary and the WhatsApp +// quick-reply edit buttons. Pure functions with no I/O — used by BOTH the +// express flow and the local flow, so neither flow has to depend on the other. + +function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; + if (includeInstruction) { + const example = elements[0]?.name || 'a field'; + text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; + } + return text; +} + +function humanizeFieldName(name) { + return name + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); +} + +// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary +// rather than cutting mid-word. +function truncateTitle(title, maxLength = 20) { + if (title.length <= maxLength) return title; + const truncated = title.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; +} + +function buildEditOptions(elements, imageId) { + return elements.map((element) => ({ + id: `edit:${imageId}:${element.name}`, + title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), + })); +} + +module.exports = { formatAllowedEdits, buildEditOptions }; diff --git a/src/express/expressApi.js b/src/express/expressApi.js index 9cc154df9c..d6a579d6e4 100644 --- a/src/express/expressApi.js +++ b/src/express/expressApi.js @@ -1,4 +1,7 @@ const { buildAuthHeaders } = require('./expressAuth'); +// Presentation helpers live in a shared module; re-exported here for backward +// compatibility with existing callers/tests. +const { formatAllowedEdits, buildEditOptions } = require('../editOptions'); function apiBaseUrl() { return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; @@ -69,43 +72,6 @@ function collectTaggedElements(taggedDocument) { return elements; } -function formatAllowedEdits(name, elements, { includeInstruction = true } = {}) { - const lines = elements.map((element) => - element.type === 'text' - ? `- ${element.name}: currently "${element.value}"` - : `- ${element.name} (${element.type})` - ); - let text = `Edits allowed on "${name}":\n${lines.join('\n')}`; - if (includeInstruction) { - const example = elements[0]?.name || 'a field'; - text += `\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; - } - return text; -} - -function humanizeFieldName(name) { - return name - .replace(/_/g, ' ') - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .toLowerCase(); -} - -// WhatsApp reply-button titles are capped at 20 characters; trim on a word boundary -// rather than cutting mid-word. -function truncateTitle(title, maxLength = 20) { - if (title.length <= maxLength) return title; - const truncated = title.slice(0, maxLength); - const lastSpace = truncated.lastIndexOf(' '); - return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; -} - -function buildEditOptions(elements, imageId) { - return elements.map((element) => ({ - id: `edit:${imageId}:${element.name}`, - title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), - })); -} - function pagesForEdits(elements, editKeys) { const pageNumbers = new Set( elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) diff --git a/src/expressFlow.js b/src/expressFlow.js new file mode 100644 index 0000000000..8beb89623b --- /dev/null +++ b/src/expressFlow.js @@ -0,0 +1,125 @@ +// ── Flow 1: Adobe Express-backed catalog designs (image.source === 'express') ─ +// Real Adobe Express API: read the tagged document, validate edits against the +// live tagged elements, generate a variation, poll, and send the rendered image. +// +// Self-contained: this module must NOT depend on the local/canned flow. It is +// reached only via the router in actions.js for images whose source is 'express'. + +const { recordEdits } = require('./imageStore'); +const expressApi = require('./express/expressApi'); +const { buildValueEditId } = require('./interactiveReply'); +const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); + +// A "change the product to a TV" request offers 3 fixed models as quick replies. +const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; + +const MAX_DISCOUNT_PERCENT = 40; + +function withCurrentEdits(elements, currentEdits) { + return elements.map((element) => + element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element + ); +} + +function isDiscountField(name) { + return /discount/i.test(name); +} + +function parsePercent(value) { + const match = String(value).match(/(\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; +} + +// TV model picker — each option id encodes the full productImage/price edits so a +// tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). +function selectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which model would you like to use?', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} + +// What can be edited? — reads the live tagged document from Adobe Express. +async function checkAllowedEdits(image) { + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: buildEditOptions(elementsWithCurrentEdits, image.id), + historyText: formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; + } catch (err) { + console.error('[expressFlow.checkAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +// Apply edits via the real Adobe Express generate-variation pipeline. +async function editGraphic(phoneNumber, image, edits, { sendImage }) { + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[expressFlow.editGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + } + + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + if (oversizedDiscountKeys.length > 0) { + return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); + } catch (err) { + console.error('[expressFlow.editGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, image.id, edits); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[expressFlow.editGraphic] sendImage error', { docId: image.docId, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +module.exports = { selectTvModel, checkAllowedEdits, editGraphic, MAX_DISCOUNT_PERCENT }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js new file mode 100644 index 0000000000..0276db81a8 --- /dev/null +++ b/src/expressFlow.test.js @@ -0,0 +1,177 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const expressFlow = require('./expressFlow'); +const expressApi = require('./express/expressApi'); +const { findTrackedImage, recordEdits } = require('./imageStore'); +const { parseEditOptionId } = require('./interactiveReply'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +// A catalog (source: 'express') image resolved through imageStore. +function catalogImage(phone) { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + return findTrackedImage(phone, 'img_1'); +} + +test('checkAllowedEdits lists the tagged elements for a known image', async () => { + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + const image = catalogImage('phone-1'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply.historyText, /Croma Earbuds/); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply.historyText, /cta: currently/); +}); + +test('checkAllowedEdits shows the latest edited value instead of the stale document value', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + recordEdits('phone-1b', 'img_1', { cta: '20% off' }); + const image = findTrackedImage('phone-1b', 'img_1'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply.historyText, /cta: currently "20% off"/); + assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); +}); + +test('checkAllowedEdits returns a friendly message when the Express API call fails', async () => { + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + const image = catalogImage('phone-3'); + + const reply = await expressFlow.checkAllowedEdits(image); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('editGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + const image = catalogImage('phone-4'); + + const reply = await expressFlow.editGraphic('phone-4', image, { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('editGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (statusUrl) => { + assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + const image = catalogImage('phone-5'); + + const reply = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const updated = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(updated.currentEdits, { cta: '20% off' }); +}); + +test('editGraphic returns a friendly message and does not record the edit when generation fails', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + const image = catalogImage('phone-6'); + + const reply = await expressFlow.editGraphic('phone-6', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const updated = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(updated.currentEdits, {}); +}); + +test('editGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); + + const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + const image = catalogImage('phone-7'); + + const reply = await expressFlow.editGraphic('phone-7', image, { cta: '20% off' }, { sendImage }); + + assert.match(reply, /couldn't send the image right now/); + assert.doesNotMatch(reply, /something went wrong generating/); + + const updated = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(updated.currentEdits, { cta: '20% off' }); +}); + +test('selectTvModel returns the 3 fixed TV model options with the question body text', () => { + const result = expressFlow.selectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); + +test('selectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { + const result = expressFlow.selectTvModel('img_1'); + + for (const option of result.options) { + const parsed = parseEditOptionId(option.id); + assert.deepEqual(parsed, { + imageId: 'img_1', + edits: { + productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + oldPrice: 33999, + price: 27199, + }, + }); + } +}); diff --git a/src/localFlow.js b/src/localFlow.js new file mode 100644 index 0000000000..fca1be8e9c --- /dev/null +++ b/src/localFlow.js @@ -0,0 +1,150 @@ +// ── Flow 2: canned-image designs created at runtime (image.source === 'local') ─ +// No Adobe Express calls. A design is created from a text description and its +// edits resolve to pre-hosted image URLs defined in data/onam-design.json. +// +// Self-contained: this module must NOT depend on the express flow. It is reached +// only via the router in actions.js for images whose source is 'local'. + +const fs = require('node:fs'); +const path = require('node:path'); +const { recordEdits, createDesign: registerDesign } = require('./imageStore'); +const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); + +function loadOnamDesign() { + const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +// create_design: register a brand-new local design and send its base image. +async function createDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { + const design = loadOnamDesign(); + const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); + const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); + registerDesign(phoneNumber, { name, design }); + + try { + await sendImage(phoneNumber, design.images.base); + } catch (err) { + console.error('[localFlow.createDesign] sendImage error', { message: err.message }); + return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + } + + return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; +} + +function normalizeKey(key) { + return String(key).toLowerCase().trim().replace(/\s+/g, '_'); +} + +// Map GPT's free-form edit keys onto the design's canonical slot names via +// aliases, so "background_color" / "colour" / "heading" all resolve correctly. +function canonicalizeEdits(editableSlots, edits) { + const canonical = {}; + const unknown = []; + for (const [rawKey, value] of Object.entries(edits || {})) { + const key = normalizeKey(rawKey); + const slot = editableSlots.find( + (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) + ); + if (slot) canonical[slot.name] = value; + else unknown.push(rawKey); + } + return { canonical, unknown }; +} + +function isPaletteColor(design, value) { + const v = String(value).trim().toLowerCase(); + return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); +} + +function hasMalayalam(value) { + return /[ഀ-ൿ]/.test(String(value)); +} + +function isMalayalamEdit(currentEdits) { + return Object.entries(currentEdits).some(([key, value]) => + hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) + ); +} + +// Pick the canned image for the current accumulated edit state. +function resolveLocalImage(design, currentEdits) { + if (isMalayalamEdit(currentEdits)) return design.images.malayalam; + if (currentEdits.background || currentEdits.address) return design.images.final; + return design.images.base; +} + +function localEditElements(image) { + return image.design.slots.editable.map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +// What can be edited? — from the design's static slot schema (no API). +function checkAllowedEdits(image) { + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: buildEditOptions(elements, image.id), + historyText: formatAllowedEdits(image.name, elements), + }; +} + +// Apply edits by resolving to the matching canned image URL. +async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { + const design = image.design; + const editableSlots = design.slots.editable; + + // Translation is special: the model may pass the Malayalam text (or the word + // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" + // request always maps to the Malayalam creative, regardless of the edit key. + const rawText = Object.entries(rawEdits || {}).flat().map(String); + const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); + + // A translation request short-circuits everything: map straight to the + // Malayalam creative. Never treat it as a field edit or run it through the + // palette / locked-field guardrails (the model may put the Malayalam text on + // any key, including one that looks like "background"). + let appliedEdits; + if (wantsMalayalam) { + appliedEdits = { language: 'Malayalam' }; + } else { + const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); + + if (unknown.length > 0) { + const editableNames = editableSlots.map((s) => s.name).join(', '); + return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; + } + + if ('background' in edits && !isPaletteColor(design, edits.background)) { + const options = design.palette.map((c) => c.name).join(' · '); + return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; + } + + appliedEdits = edits; + } + + recordEdits(phoneNumber, image.id, appliedEdits); + const currentEdits = { ...image.currentEdits, ...appliedEdits }; + const imageUrl = resolveLocalImage(design, currentEdits); + console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + + const summary = wantsMalayalam + ? '• translated to Malayalam' + : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, imageUrl); + } catch (err) { + console.error('[localFlow.editGraphic] sendImage error', { imageId: image.id, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/createDesign.test.js b/src/localFlow.test.js similarity index 51% rename from src/createDesign.test.js rename to src/localFlow.test.js index 5165ceff62..4cd05410f2 100644 --- a/src/createDesign.test.js +++ b/src/localFlow.test.js @@ -3,7 +3,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { actionCreateDesign, actionEditGraphic } = require('./actions'); +const localFlow = require('./localFlow'); +const { findTrackedImage } = require('./imageStore'); const FIXTURE = { images: { @@ -37,11 +38,19 @@ function captureSendImage() { return { sendImage: async (_to, link) => sent.push(link), sent }; } -test('actionCreateDesign registers a local design and sends the base image', async () => { +// Create a design for a phone (base image sent) and return its tracked image object. +async function setup(phone, args = { occasion: 'Onam' }) { + useFixture(); + const { sendImage, sent } = captureSendImage(); + await localFlow.createDesign(phone, args, { sendImage }); + return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; +} + +test('createDesign registers a local design and sends the base image', async () => { useFixture(); const { sendImage, sent } = captureSendImage(); - const reply = await actionCreateDesign( + const reply = await localFlow.createDesign( 'onam-phone-1', { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, { sendImage } @@ -51,12 +60,20 @@ test('actionCreateDesign registers a local design and sends the base image', asy assert.match(reply, /Built with Croma's logo/); }); -test('editing a created design to an off-palette background is rejected with approved options', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-2', { occasion: 'Onam' }, { sendImage }); +test('checkAllowedEdits lists the editable slots from the static schema', async () => { + const { image } = await setup('onam-phone-check'); - const reply = await actionEditGraphic('onam-phone-2', 'local_1', { background: 'Pink' }, { sendImage }); + const result = localFlow.checkAllowedEdits(image); + + assert.equal(result.type, 'edit_options'); + assert.deepEqual(result.options.map((o) => o.title), ['Change headline', 'Change background', 'Change address']); + assert.match(result.historyText, /headline/); +}); + +test('editing to an off-palette background is rejected with the approved options', async () => { + const { image, sent } = await setup('onam-phone-2'); + + const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l) => sent.push(l) }); assert.match(reply, /isn't in the approved palette/); assert.match(reply, /Marigold · Maroon · Deep Green/); @@ -64,85 +81,67 @@ test('editing a created design to an off-palette background is rejected with app }); test('an approved-palette background + address resolves to the final image', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-3', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-3'); - await actionEditGraphic('onam-phone-3', 'local_1', { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage }); + await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); test('a Malayalam headline resolves to the Malayalam image', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-4', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-4'); - await actionEditGraphic('onam-phone-4', 'local_1', { headline: 'ഓണം ആശംസകൾ' }, { sendImage }); + await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); test('adding only a store address resolves to the final image (demo msg 2)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-a', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-a'); - await actionEditGraphic('onam-phone-a', 'local_1', { address: 'Princess Street, Kochi' }, { sendImage }); + await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-b', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-b'); - // model phrases it as a language change rather than a headline edit - const reply = await actionEditGraphic('onam-phone-b', 'local_1', { language: 'Malayalam' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-c', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-c'); - await actionEditGraphic('onam-phone-c', 'local_1', { banner: 'ഓണം ആശംസകൾ' }, { sendImage }); + await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); }); test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-d', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-d'); - // model mistakenly puts the translated word on a "background"-like key - const reply = await actionEditGraphic('onam-phone-d', 'local_1', { background: 'ഓണം' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.malayalam); assert.doesNotMatch(reply, /approved palette/); }); -test('editing a locked element on a created design is refused', async () => { - useFixture(); - const { sendImage } = captureSendImage(); - await actionCreateDesign('onam-phone-5', { occasion: 'Onam' }, { sendImage }); +test('editing a locked element is refused', async () => { + const { image } = await setup('onam-phone-5'); - const reply = await actionEditGraphic('onam-phone-5', 'local_1', { logo: 'brighter' }, { sendImage }); + const reply = await localFlow.editGraphic('onam-phone-5', image, { logo: 'brighter' }, { sendImage: async () => {} }); assert.match(reply, /locked by HQ/); }); test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - await actionCreateDesign('onam-phone-6', { occasion: 'Onam' }, { sendImage }); + const { image, sent } = await setup('onam-phone-6'); - await actionEditGraphic('onam-phone-6', 'local_1', { colour: 'Deep Green' }, { sendImage }); + await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l) => sent.push(l) }); assert.equal(sent.at(-1), FIXTURE.images.final); }); From 1616d3dd16897a7f8b3470a1f13696d607560a21 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:22:19 +0530 Subject: [PATCH 17/38] feat(local): guided create flow with button follow-ups and image captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the dealer-guided local flow: describe products+offer → assistant suggests Onam → asks to add address → generates the creative → offer to change → translate to Malayalam. Uses only the onam-final and onam-malayalam creatives. - ask_for_more_information can now carry options (e.g. ["Yes","No"]) rendered as tappable WhatsApp reply buttons; the tapped title flows back as the user's text - create_design takes includeAddress; when true the with-address ("final") creative is sent (records the address so later edits keep that state) - images are now sent WITH a friendly caption describing the offer / the change, so text + image arrive together like a real promo - widen the GPT context window (last 3 → last 12) so multi-step follow-ups retain the original product/offer request; system prompt drives the guided flow - tests: cover includeAddress → final image, captions, and button-reply parsing Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.test.js | 2 +- src/app.js | 46 ++++++++++++++++++++++++++------ src/localFlow.js | 39 +++++++++++++++++---------- src/localFlow.test.js | 61 ++++++++++++++++++++++++++++--------------- 4 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/actions.test.js b/src/actions.test.js index 2f0603f92b..26aef77d04 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -49,6 +49,6 @@ test('routes a local-source image to the canned flow (no Express API call)', asy const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - assert.match(reply, /Updated/); + assert.match(reply, /anything else/i); assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/app.js b/src/app.js index 276d602d3d..38460709bb 100644 --- a/src/app.js +++ b/src/app.js @@ -60,8 +60,9 @@ function sendText(to, text) { return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); } -function sendImage(to, link) { - return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); +function sendImage(to, link, caption) { + const image = caption ? { link, caption } : { link }; + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image }); } // WhatsApp reply-button messages support at most 3 buttons. @@ -95,6 +96,13 @@ async function sendEditOptions(to, { bodyText, options }) { } } +// Follow-up yes/no (or short multiple-choice) questions rendered as tappable +// buttons. The tapped title flows back as the user's text (see interactiveReply). +function sendQuickReplies(to, question, options) { + const buttons = options.slice(0, BUTTONS_PER_MESSAGE).map((label) => ({ id: `qr:${label}`, title: label })); + return sendButtons(to, question, buttons); +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -122,6 +130,10 @@ const tools = [ description: 'Products to feature, e.g. ["LG washing machine", "dishwasher"]', }, offer: { type: 'string', description: 'The offer or discount to show, e.g. "20% off"' }, + includeAddress: { + type: 'boolean', + description: "Set true if the user has agreed to include their store address on the design; otherwise false.", + }, }, required: ['occasion'], }, @@ -131,11 +143,16 @@ const tools = [ type: 'function', function: { name: 'ask_for_more_information', - description: 'Ask the user a clarifying question when the request is ambiguous or incomplete', + description: 'Ask the user a clarifying question when the request is ambiguous or incomplete. For yes/no or short multiple-choice questions, pass options so the user gets tappable buttons instead of typing.', parameters: { type: 'object', properties: { question: { type: 'string', description: 'The clarifying question to send to the user' }, + options: { + type: 'array', + items: { type: 'string' }, + description: 'Optional short answer choices to render as tappable buttons, e.g. ["Yes","No"] (max 3).', + }, }, required: ['question'], }, @@ -208,7 +225,9 @@ const tools = [ // ── GPT decision engine ────────────────────────────────────────────────────── async function decideAction(phoneNumber, userMessage) { - const last3 = getHistory(phoneNumber).slice(-3); + // Keep a wide window so multi-step flows (e.g. "design X" → "Onam? yes" → + // "address? yes" → create) still see the original product/offer request. + const recentHistory = getHistory(phoneNumber).slice(-12); const trackedImages = getTrackedImages(phoneNumber); const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); @@ -220,7 +239,12 @@ Analyze the user's message and conversation history, then call the appropriate t Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. -If the user wants a brand-new creative for an occasion that has no existing template (e.g. Onam, Pongal), use create_design. +Creating a brand-new design (create_design), gather details first with tappable buttons: +- If the user asks for a design but does NOT mention an occasion/festival, first call ask_for_more_information with options ["Yes","No"] to suggest Onam, e.g. "This is a great time for an Onam offer — want me to generate this for Onam?". +- Once an occasion is agreed (or was given up front), and before creating, call ask_for_more_information with options ["Yes","No"] to ask "Great! Do you also want to add your store address?". +- Then call create_design with the occasion, the products and offer mentioned earlier in the conversation, and includeAddress set from their address answer. +- Always attach options ["Yes","No"] to any yes/no question so the user can tap a button instead of typing. +- After the design is sent, if the user asks to change something (e.g. "change the language to Malayalam"), call edit_graphic. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. @@ -228,7 +252,7 @@ If the user asks to translate a tag's text into another language (e.g. "change t Images previously sent to this user (reference by id): ${imagesList}`, }, - ...last3, + ...recentHistory, { role: 'user', content: userMessage }, ]; @@ -306,8 +330,14 @@ app.post('/', async (req, res) => { break; case 'ask_for_more_information': - console.log('[action:ask_for_more_information]', { question: args.question }); - replyText = args.question; + console.log('[action:ask_for_more_information]', { question: args.question, options: args.options }); + if (Array.isArray(args.options) && args.options.length > 0) { + await sendQuickReplies(phoneNumber, args.question, args.options); + replyText = args.question; // kept for conversation history + skipSend = true; + } else { + replyText = args.question; + } break; case 'check_allowed_edits': { diff --git a/src/localFlow.js b/src/localFlow.js index fca1be8e9c..e23e516164 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -15,22 +15,33 @@ function loadOnamDesign() { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -// create_design: register a brand-new local design and send its base image. -async function createDesign(phoneNumber, { occasion, products, offer } = {}, { sendImage }) { +// create_design: register a brand-new local design and send its image with a +// friendly caption. If the user opted to include their address, the creative +// that carries it is the "final" image (we only ship 2 canned URLs for this flow). +async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage }) { const design = loadOnamDesign(); const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); - const name = [occasion || 'Festive', productList, 'offer'].filter(Boolean).join(' '); - console.log('[action:create_design]', { phoneNumber, occasion, products, offer, image: design.images.base }); - registerDesign(phoneNumber, { name, design }); + const festive = occasion || 'Festive'; + const name = [festive, productList, 'offer'].filter(Boolean).join(' '); + const image = registerDesign(phoneNumber, { name, design }); + + const imageUrl = includeAddress ? design.images.final : design.images.base; + if (includeAddress) recordEdits(phoneNumber, image.id, { address: 'your store' }); + + console.log('[action:create_design]', { phoneNumber, occasion, products, offer, includeAddress, image: imageUrl }); + + const offerText = offer ? ` at ${offer}` : ''; + const addressText = includeAddress ? ', with your store address' : ''; + const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; try { - await sendImage(phoneNumber, design.images.base); + await sendImage(phoneNumber, imageUrl, caption); } catch (err) { console.error('[localFlow.createDesign] sendImage error', { message: err.message }); - return `I built your ${occasion || 'festive'} design, but couldn't send the image right now — try asking me to resend it.`; + return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; } - return `Here you go 🌼 Built with Croma's logo, approved festive colours and the ${productList} images. Want to tweak anything?`; + return 'Want to change anything? For example, I can translate the whole banner to Malayalam. 🌸'; } function normalizeKey(key) { @@ -133,18 +144,18 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); - const summary = wantsMalayalam - ? '• translated to Malayalam' - : Object.entries(appliedEdits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + const caption = wantsMalayalam + ? '🌸 Here you go — your banner is now in Malayalam!' + : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; try { - await sendImage(phoneNumber, imageUrl); + await sendImage(phoneNumber, imageUrl, caption); } catch (err) { console.error('[localFlow.editGraphic] sendImage error', { imageId: image.id, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + return `I updated "${image.name}", but couldn't send the image right now — try asking me to resend it.`; } - return `Updated "${image.name}":\n${summary}`; + return "Anything else you'd like to change?"; } module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index 4cd05410f2..cccb7f42ee 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -33,12 +33,14 @@ function useFixture() { process.env.ONAM_DESIGN_FILE = p; } +// Captures both the image link and its caption so we can assert on the +// descriptive text sent alongside each image. function captureSendImage() { const sent = []; - return { sendImage: async (_to, link) => sent.push(link), sent }; + return { sendImage: async (_to, link, caption) => sent.push({ link, caption }), sent }; } -// Create a design for a phone (base image sent) and return its tracked image object. +// Create a design for a phone (image sent) and return its tracked image object. async function setup(phone, args = { occasion: 'Onam' }) { useFixture(); const { sendImage, sent } = captureSendImage(); @@ -46,7 +48,7 @@ async function setup(phone, args = { occasion: 'Onam' }) { return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; } -test('createDesign registers a local design and sends the base image', async () => { +test('createDesign registers a local design and sends the base image with a caption', async () => { useFixture(); const { sendImage, sent } = captureSendImage(); @@ -56,8 +58,24 @@ test('createDesign registers a local design and sends the base image', async () { sendImage } ); - assert.equal(sent[0], FIXTURE.images.base); - assert.match(reply, /Built with Croma's logo/); + assert.equal(sent[0].link, FIXTURE.images.base); + assert.match(sent[0].caption, /Onam/); + assert.match(sent[0].caption, /20% off/); + assert.match(reply, /change anything/i); +}); + +test('createDesign with includeAddress sends the with-address (final) image', async () => { + useFixture(); + const { sendImage, sent } = captureSendImage(); + + await localFlow.createDesign( + 'onam-phone-addr', + { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, + { sendImage } + ); + + assert.equal(sent[0].link, FIXTURE.images.final); + assert.match(sent[0].caption, /store address/); }); test('checkAllowedEdits lists the editable slots from the static schema', async () => { @@ -73,7 +91,7 @@ test('checkAllowedEdits lists the editable slots from the static schema', async test('editing to an off-palette background is rejected with the approved options', async () => { const { image, sent } = await setup('onam-phone-2'); - const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); assert.match(reply, /isn't in the approved palette/); assert.match(reply, /Marigold · Maroon · Deep Green/); @@ -83,50 +101,51 @@ test('editing to an off-palette background is rejected with the approved options test('an approved-palette background + address resolves to the final image', async () => { const { image, sent } = await setup('onam-phone-3'); - await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); -test('a Malayalam headline resolves to the Malayalam image', async () => { +test('a Malayalam headline resolves to the Malayalam image with a Malayalam caption', async () => { const { image, sent } = await setup('onam-phone-4'); - await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); + assert.match(sent.at(-1).caption, /Malayalam/); }); test('adding only a store address resolves to the final image (demo msg 2)', async () => { const { image, sent } = await setup('onam-phone-a'); - await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { const { image, sent } = await setup('onam-phone-b'); - const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { const { image, sent } = await setup('onam-phone-c'); - await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); }); test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { const { image, sent } = await setup('onam-phone-d'); - const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l) => sent.push(l) }); + const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); assert.doesNotMatch(reply, /approved palette/); }); @@ -141,7 +160,7 @@ test('editing a locked element is refused', async () => { test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { const { image, sent } = await setup('onam-phone-6'); - await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l) => sent.push(l) }); + await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1), FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.final); }); From fd41439ec232f07a740c9882528c4172bbcbd3c7 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:40:13 +0530 Subject: [PATCH 18/38] feat(local): stream "work being done" and add a realistic generation delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation no longer returns instantly. create_design and the local edit path now stream progress messages one at a time (with a tunable pause) before sending the image, so it feels like real work. - streamProgress() sends step messages with a GEN_STEP_DELAY_MS pause (default 1800ms); no-op when no sendText is provided, so unit tests stay instant - create streams: creating → pulling brand kit → adding products → applying offer/styling → (if chosen) placing address, then the image - Malayalam edit streams: translating → re-rendering, then the image - messages are dynamic (name the actual products/offer; address step only when address was requested) - sendText is threaded through actions → both flows; app.js drops the old static pre-step texts; expressFlow shows a single guarded re-render notice - demo-onam-flow.sh waits for generation before the next step (GEN_PAUSE) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.js | 6 +++--- src/app.js | 8 ++++---- src/expressFlow.js | 4 +++- src/localFlow.js | 36 ++++++++++++++++++++++++++++++++++-- src/localFlow.test.js | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/actions.js b/src/actions.js index 90662359b3..17f186a094 100644 --- a/src/actions.js +++ b/src/actions.js @@ -43,14 +43,14 @@ async function actionCheckAllowedEdits(phoneNumber, imageId) { } // Router: resolve the image, then hand off to the flow that owns it. -async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage, sendText }) { const image = findTrackedImage(phoneNumber, imageId); console.log('[action:edit_graphic]', { phoneNumber, imageId, source: image?.source ?? 'not_found', edits }); if (!image) return formatUnknownImageMessage(phoneNumber); return image.source === 'local' - ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage }) - : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage }); + ? localFlow.editGraphic(phoneNumber, image, edits, { sendImage, sendText }) + : expressFlow.editGraphic(phoneNumber, image, edits, { sendImage, sendText }); } async function actionGenerateBulkGraphics(filename) { diff --git a/src/app.js b/src/app.js index 38460709bb..7bc7806901 100644 --- a/src/app.js +++ b/src/app.js @@ -325,8 +325,8 @@ app.post('/', async (req, res) => { break; case 'create_design': - await sendText(phoneNumber, '🎨 Creating your design, this may take a moment...'); - replyText = await actionCreateDesign(phoneNumber, args, { sendImage }); + // Progress is streamed from inside the flow (with the product/offer context). + replyText = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); break; case 'ask_for_more_information': @@ -362,8 +362,8 @@ app.post('/', async (req, res) => { } case 'edit_graphic': - await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); - replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + // Progress is streamed from inside the flow. + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); break; case 'generate_bulk_graphics': diff --git a/src/expressFlow.js b/src/expressFlow.js index 8beb89623b..6c993cc76c 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -64,7 +64,7 @@ async function checkAllowedEdits(image) { } // Apply edits via the real Adobe Express generate-variation pipeline. -async function editGraphic(phoneNumber, image, edits, { sendImage }) { +async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); @@ -93,6 +93,8 @@ async function editGraphic(phoneNumber, image, edits, { sendImage }) { return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; } + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); diff --git a/src/localFlow.js b/src/localFlow.js index e23e516164..6f3f30e739 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -15,10 +15,26 @@ function loadOnamDesign() { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Send progress ("streaming") messages one at a time with a pause between them, +// so generation feels like real work rather than an instant response. No-op when +// no sender is provided (keeps unit tests fast). Delay is tunable for the demo. +async function streamProgress(sendText, phoneNumber, messages) { + if (typeof sendText !== 'function') return; + const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1800); + for (const message of messages) { + await sendText(phoneNumber, message); + await sleep(delay); + } +} + // create_design: register a brand-new local design and send its image with a // friendly caption. If the user opted to include their address, the creative // that carries it is the "final" image (we only ship 2 canned URLs for this flow). -async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage }) { +async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage, sendText }) { const design = loadOnamDesign(); const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); const festive = occasion || 'Festive'; @@ -30,6 +46,16 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd console.log('[action:create_design]', { phoneNumber, occasion, products, offer, includeAddress, image: imageUrl }); + // Stream the "work being done" so generation feels real, then send the image. + const steps = [ + `🎨 Got it — creating your ${festive} creative now. Give me a few seconds…`, + `📦 Pulling Croma's logo and the approved ${festive} colour palette from the brand kit…`, + `📱 Adding your products: ${productList}…`, + `🏷️ Applying ${offer ? `your ${offer} offer` : 'your offer'} and festive ${festive} styling…`, + ]; + if (includeAddress) steps.push('📍 Placing your store address…'); + await streamProgress(sendText, phoneNumber, steps); + const offerText = offer ? ` at ${offer}` : ''; const addressText = includeAddress ? ', with your store address' : ''; const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; @@ -106,7 +132,7 @@ function checkAllowedEdits(image) { } // Apply edits by resolving to the matching canned image URL. -async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { +async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText }) { const design = image.design; const editableSlots = design.slots.editable; @@ -144,6 +170,12 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage }) { const imageUrl = resolveLocalImage(design, currentEdits); console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + // Stream progress so the re-render feels real, then send the updated image. + const progress = wantsMalayalam + ? ['🌸 Translating your banner to Malayalam…', '✍️ Re-rendering with the Malayalam text…'] + : ['✍️ Updating your creative…']; + await streamProgress(sendText, phoneNumber, progress); + const caption = wantsMalayalam ? '🌸 Here you go — your banner is now in Malayalam!' : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index cccb7f42ee..306cbc5dc7 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -78,6 +78,41 @@ test('createDesign with includeAddress sends the with-address (final) image', as assert.match(sent[0].caption, /store address/); }); +test('createDesign streams progress messages (products + address) before the image', async () => { + useFixture(); + process.env.GEN_STEP_DELAY_MS = '0'; // no real pause in tests + const texts = []; + const sendText = async (_to, msg) => texts.push(msg); + const { sendImage, sent } = captureSendImage(); + + await localFlow.createDesign( + 'onam-stream-1', + { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, + { sendImage, sendText } + ); + + assert.ok(texts.length >= 4, 'streams several progress messages'); + assert.ok(texts.some((m) => /Samsung Galaxy S26 \+ Galaxy Buds 3/.test(m)), 'names the products'); + assert.ok(texts.some((m) => /store address/i.test(m)), 'mentions the address step'); + assert.equal(sent[0].link, FIXTURE.images.final); // image sent after the stream +}); + +test('editGraphic streams Malayalam progress before sending the Malayalam image', async () => { + process.env.GEN_STEP_DELAY_MS = '0'; + const { image } = await setup('onam-stream-2'); + const texts = []; + const sent = []; + const sendText = async (_to, msg) => texts.push(msg); + + await localFlow.editGraphic('onam-stream-2', image, { language: 'Malayalam' }, { + sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }), + sendText, + }); + + assert.ok(texts.some((m) => /Malayalam/i.test(m)), 'streams a Malayalam progress message'); + assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); +}); + test('checkAllowedEdits lists the editable slots from the static schema', async () => { const { image } = await setup('onam-phone-check'); From 2fd4cf3d6678a45227ae93c7f99170fbb1343e39 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Wed, 22 Jul 2026 18:52:39 +0530 Subject: [PATCH 19/38] fix(local): put the follow-up in the image caption so it can't precede the image WhatsApp delivers link images a beat after plain text, so a follow-up sent as a separate message after the image ("Want to change anything?" / "Anything else?") was showing up before the image. - fold the follow-up prompt into the image caption for create and edit success paths; the flow sends the image+caption and returns { skipSend, historyText } so app.js no longer sends a separate (racing) text - app.js create_design/edit_graphic handle the string-vs-object return like check_allowed_edits already does; guardrail rejections still return a string - apply the same caption treatment to the express edit path (latent race) - tests updated for the new return shape / caption assertions Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions.test.js | 2 +- src/app.js | 26 +++++++++++++++++++++----- src/expressFlow.js | 6 ++++-- src/expressFlow.test.js | 2 +- src/localFlow.js | 12 ++++++++---- src/localFlow.test.js | 11 ++++++----- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/actions.test.js b/src/actions.test.js index 26aef77d04..a0d2d6b2b6 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -49,6 +49,6 @@ test('routes a local-source image to the canned flow (no Express API call)', asy const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); - assert.match(reply, /anything else/i); + assert.match(reply.historyText, /anything else/i); assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran }); diff --git a/src/app.js b/src/app.js index 7bc7806901..b6f246dfa2 100644 --- a/src/app.js +++ b/src/app.js @@ -324,10 +324,18 @@ app.post('/', async (req, res) => { replyText = await actionListCampaignGraphics(); break; - case 'create_design': + case 'create_design': { // Progress is streamed from inside the flow (with the product/offer context). - replyText = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); + // On success the flow already sent the image+caption, so skip the extra text. + const result = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = result.historyText; + skipSend = true; + } break; + } case 'ask_for_more_information': console.log('[action:ask_for_more_information]', { question: args.question, options: args.options }); @@ -361,10 +369,18 @@ app.post('/', async (req, res) => { break; } - case 'edit_graphic': - // Progress is streamed from inside the flow. - replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + case 'edit_graphic': { + // Progress is streamed from inside the flow. On success the flow already + // sent the image+caption; a guardrail rejection returns a plain string. + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = result.historyText; + skipSend = true; + } break; + } case 'generate_bulk_graphics': await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); diff --git a/src/expressFlow.js b/src/expressFlow.js index 6c993cc76c..10e4a24ccd 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -113,15 +113,17 @@ async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { recordEdits(phoneNumber, image.id, edits); const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + // Caption carries the summary so text never arrives before the image. + const caption = `Updated "${image.name}":\n${summary}\n\nAnything else you'd like to change?`; try { - await sendImage(phoneNumber, thumbnailUrl); + await sendImage(phoneNumber, thumbnailUrl, caption); } catch (err) { console.error('[expressFlow.editGraphic] sendImage error', { docId: image.docId, message: err.message }); return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; } - return `Updated "${image.name}":\n${summary}`; + return { skipSend: true, historyText: caption }; } module.exports = { selectTvModel, checkAllowedEdits, editGraphic, MAX_DISCOUNT_PERCENT }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 0276db81a8..26e091b573 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -105,7 +105,7 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, sends th const reply = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, { sendImage }); - assert.match(reply, /Updated "Croma Earbuds"/); + assert.match(reply.historyText, /Updated "Croma Earbuds"/); assert.equal(sentCalls.length, 1); assert.equal(sentCalls[0].to, 'phone-5'); assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); diff --git a/src/localFlow.js b/src/localFlow.js index 6f3f30e739..afedeebf3a 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -58,7 +58,9 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd const offerText = offer ? ` at ${offer}` : ''; const addressText = includeAddress ? ', with your store address' : ''; - const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨`; + // The follow-up prompt lives inside the caption so it never arrives before the + // image — WhatsApp delivers link images a beat after plain text. + const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨\n\nWant to change anything? For example, I can translate the whole banner to Malayalam. 🌸`; try { await sendImage(phoneNumber, imageUrl, caption); @@ -67,7 +69,7 @@ async function createDesign(phoneNumber, { occasion, products, offer, includeAdd return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; } - return 'Want to change anything? For example, I can translate the whole banner to Malayalam. 🌸'; + return { skipSend: true, historyText: caption }; } function normalizeKey(key) { @@ -176,9 +178,11 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText } : ['✍️ Updating your creative…']; await streamProgress(sendText, phoneNumber, progress); - const caption = wantsMalayalam + const summary = wantsMalayalam ? '🌸 Here you go — your banner is now in Malayalam!' : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; + // Follow-up lives in the caption so it can't arrive before the image. + const caption = `${summary}\n\nAnything else you'd like to change?`; try { await sendImage(phoneNumber, imageUrl, caption); @@ -187,7 +191,7 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText } return `I updated "${image.name}", but couldn't send the image right now — try asking me to resend it.`; } - return "Anything else you'd like to change?"; + return { skipSend: true, historyText: caption }; } module.exports = { createDesign, checkAllowedEdits, editGraphic }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index 306cbc5dc7..ffbf2be065 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -61,7 +61,8 @@ test('createDesign registers a local design and sends the base image with a capt assert.equal(sent[0].link, FIXTURE.images.base); assert.match(sent[0].caption, /Onam/); assert.match(sent[0].caption, /20% off/); - assert.match(reply, /change anything/i); + assert.match(sent[0].caption, /change anything/i); // follow-up is in the caption, not a separate text + assert.equal(reply.skipSend, true); }); test('createDesign with includeAddress sends the with-address (final) image', async () => { @@ -161,10 +162,10 @@ test('adding only a store address resolves to the final image (demo msg 2)', asy test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { const { image, sent } = await setup('onam-phone-b'); - const reply = await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + // resolving to the Malayalam image (rather than a "locked by HQ" text) proves it wasn't rejected assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); - assert.doesNotMatch(reply, /locked by HQ/); }); test('a Malayalam value under any key resolves to Malayalam', async () => { @@ -178,10 +179,10 @@ test('a Malayalam value under any key resolves to Malayalam', async () => { test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { const { image, sent } = await setup('onam-phone-d'); - const reply = await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + // resolving to the Malayalam image proves it wasn't palette-rejected assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); - assert.doesNotMatch(reply, /approved palette/); }); test('editing a locked element is refused', async () => { From 555526babf4e004ea05708f88feefcaeef72d36a Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:15:02 +0530 Subject: [PATCH 20/38] Add Edit Product/Discount/Price menu with Hinglish-mirrored replies (#13) * Always render edit options as native reply buttons Replace the list-picker fallback with additional button messages (3 per message, WhatsApp's per-message cap) so options over 3 still show up as tappable buttons instead of a "Choose a field" dropdown. Co-Authored-By: Claude Sonnet 5 * Simplify edit-options prompt and button labels Show a short "What would you like to change?" prompt instead of dumping every field's current value into the button message body, and humanize camelCase field names (e.g. discountPercentage -> "Change discount") instead of only stripping _text/_image suffixes. The detailed field listing is still kept in conversation history for GPT context. Co-Authored-By: Claude Sonnet 5 * Wire quick-reply button taps to the actual field/image being edited Button/list-row ids now encode edit:: instead of just a display title, so tapping an edit option tells GPT precisely which field to edit instead of a truncated, ambiguous label. GPT now asks for the missing value and applies it via edit_graphic once supplied, rather than never reaching the Express API. Co-Authored-By: Claude Sonnet 5 * Add design doc for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Add implementation plan for TV product-swap quick-reply flow Co-Authored-By: Claude Sonnet 5 * Extract interactive-reply id parsing into a testable module * Support fully-specified multi-field edits in interactive reply ids * Add actionSelectTvModel handler for the TV product-swap quick replies * Wire select_tv_model GPT tool into the webhook handler * Add design doc for Edit Product/Discount/Price menu with Hinglish-mirrored replies Specifies the fixed 3-button menu, WhatsApp list picker, generalized discount-cap validation, and GPT-phrased language-mirrored responses requested for the Express-catalog edit flow. * Generalize discount cap to price edits and return structured edit outcomes editExpressDesign now rejects any price edit implying more than 40% off (not just literal "discount" fields), with rounding tolerance for whole-rupee prices, and returns structured outcome objects instead of hardcoded reply strings so the caller can phrase the final reply. * Replace dynamic edit menu with fixed Edit Product/Discount/Price for Express-catalog images Express-catalog graphics now always show the same 3-option menu instead of a per-document field list, so choosing what to edit no longer needs an Express API round trip. Local/Onam-style designs are unaffected. * Present TV model picker as a list with Choose product button * Add WhatsApp list-message support for the product picker * Add GPT-phrased, language-mirrored edit replies and bare-field discount/price routing Express-catalog edit outcomes are now phrased by a second GPT call that mirrors the user's language style (English or Hinglish), and the fixed menu's discount/price bare fields get explicit system-prompt rules for computing a new price from a requested discount percentage. * Add implementation plan; amend spec with discount-cap rounding tolerance The spec's cap check needed a rounding-tolerance clause: whole-rupee price rounding means an at-cap request never implies exactly 40.0000%, so a strict > 40 check would reject the script's own 40%-cap example. --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- ...-07-23-product-discount-price-edit-flow.md | 734 ++++++++++++++++++ ...product-discount-price-edit-flow-design.md | 197 +++++ src/actions.js | 7 + src/app.js | 85 +- src/expressFlow.js | 120 ++- src/expressFlow.test.js | 152 ++-- 6 files changed, 1188 insertions(+), 107 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md create mode 100644 docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md diff --git a/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md b/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md new file mode 100644 index 0000000000..e4ecc9222f --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-product-discount-price-edit-flow.md @@ -0,0 +1,734 @@ +# Edit Product/Discount/Price Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the dynamic per-document edit menu with a fixed "Edit Product / Edit Discount / Edit Price" quick-reply menu for Express-catalog graphics, add a WhatsApp list picker for choosing a TV model, generalize the 40%-discount cap to cover GPT-computed price edits, and make the bot's confirmation/rejection replies mirror the user's language (English or Hinglish) via a GPT phrasing pass. + +**Architecture:** `actions.js` gains a fixed-menu builder and a generalized price/discount cap check, and its Express-edit path (`editExpressDesign`) switches from returning hardcoded reply strings to structured outcome objects (`{ status, ... }`). `app.js` gains a WhatsApp list-message sender, a second (tool-free) OpenAI call that phrases those structured outcomes into user-facing text matching the user's language style, and new system-prompt rules routing the fixed menu's three bare fields ("product"/"discount"/"price") to the right tool, including GPT-side discount→price computation. Local/Onam-design edits (`source: 'local'`) are untouched throughout. + +**Tech Stack:** Node.js, Express, `openai` SDK (chat completions, tool calling), WhatsApp Cloud API interactive messages, `node --test` + `node:assert/strict`. + +## Global Constraints + +- `MAX_DISCOUNT_PERCENT = 40` (existing, `src/actions.js:54`) — the hard cap on any discount, however it's expressed. +- New `ROUNDING_TOLERANCE_PERCENT = 0.5` — whole-rupee price rounding means a requested discount at exactly the cap can imply 40.0012% or 39.9988%; the cap check compares against `MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT`, not a strict `>`. +- WhatsApp reply-button messages cap out at 3 buttons per message (existing `BUTTONS_PER_MESSAGE = 3`, `src/app.js:85`) — unchanged, still used for the fixed 3-option menu. +- WhatsApp list-message row titles must stay under ~24 characters — the 3 existing TV model titles ("Sony Bravia K-75", "LG UA82 AI", "Samsung UA4") already fit; no truncation logic needed for this feature. +- This feature applies **only** to `source: 'express'` tracked images (the Express-catalog templates). `source: 'local'` (Onam-style canned designs) keeps today's dynamic field-list menu and plain-string replies — do not touch `editLocalDesign`, `localEditElements`, or their tests. +- `app.js` has zero automated unit tests today and cannot be safely `require()`'d in a test file: constructing the module-level `OpenAI` client throws immediately without `OPENAI_API_KEY` set, and the file calls `app.listen(...)` unconditionally at load time. Per existing project convention, do **not** add unit tests for `sendList`, `phraseOutcome`, the dispatch wiring, or the system-prompt changes — verify those via the manual/simulated webhook check in the final task instead. All automated test coverage in this plan lives in `actions.test.js` (pure/deterministic logic only). +- Existing constants stay as-is and must not be redefined: `TV_PLACEHOLDER_IMAGE_URL`, `TV_MODEL_TITLES`, `TV_MODEL_EDITS` (`src/actions.js:7-9`). + +--- + +### Task 1: Generalize the discount cap and switch Express edits to structured outcomes + +**Files:** +- Modify: `src/actions.js:54-63` (keep `MAX_DISCOUNT_PERCENT`/`isDiscountField`/`parsePercent` as-is, add new helpers alongside) +- Modify: `src/actions.js:204-260` (`editExpressDesign`) +- Test: `src/actions.test.js:74-150` (update existing `actionEditGraphic` tests, add new ones) + +**Interfaces:** +- Consumes: `expressApi.getTaggedDocument`, `expressApi.collectTaggedElements`, `expressApi.generateVariation`, `expressApi.pollJobStatus`, `expressApi.pagesForEdits`, `expressApi.buildPreferredDocumentName`, `expressApi.formatAllowedEdits` (all unchanged signatures), `recordEdits`, `withCurrentEdits` (unchanged). +- Produces: `editExpressDesign(phoneNumber, image, edits, { sendImage })` now resolves to one of: + - `{ status: 'api_error', productName, reason: 'lookup_failed' | 'generate_failed' }` + - `{ status: 'disallowed_fields', productName, disallowedKeys, allowedSummary }` + - `{ status: 'discount_capped', productName, maxPercent }` + - `{ status: 'delivery_failed', productName, changes }` + - `{ status: 'success', productName, changes }` + + `actionEditGraphic` is an unchanged pass-through, so it now returns a **string** for `source: 'local'` images and an **object** (one of the shapes above) for `source: 'express'` images. Task 5 updates `app.js`'s dispatch to handle both. + +- [ ] **Step 1: Update the existing `actionEditGraphic` tests in `src/actions.test.js` to expect structured outcomes** + +Replace the test at line 74 (`'actionEditGraphic rejects edits outside the tagged elements...'`): + +```js +test('actionEditGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const result = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.equal(result.status, 'disallowed_fields'); + assert.equal(result.productName, 'Croma Earbuds'); + assert.deepEqual(result.disallowedKeys, ['background_color']); + assert.match(result.allowedSummary, /Edits allowed on "Croma Earbuds"/); + assert.equal(sendImageCalled, false); +}); +``` + +Replace the test at line 89 (`'actionEditGraphic applies an allowed edit end-to-end...'`) — keep the same setup, change only the assertions after the call: + +```js + const result = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.deepEqual(result, { status: 'success', productName: 'Croma Earbuds', changes: { cta: '20% off' } }); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +``` + +Replace the test at line 118 (`'actionEditGraphic returns a friendly message and does not record the edit when generation fails'`) — keep setup, change assertions: + +```js + const result = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'generate_failed'); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +``` + +Replace the test at line 135 (`'actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws'`) — keep setup, change assertions: + +```js + const result = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.equal(result.status, 'delivery_failed'); + assert.deepEqual(result.changes, { cta: '20% off' }); + + const image = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +``` + +- [ ] **Step 2: Add new tests for the lookup-failure status and the generalized discount cap** + +Add to `src/actions.test.js`, after the tests updated in Step 1: + +```js +test('actionEditGraphic returns an api_error/lookup_failed status when getTaggedDocument fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { throw new Error('getTaggedDocument failed 500: boom'); }; + + const result = await actionEditGraphic('phone-8', 'img_1', { cta: '20% off' }, { sendImage: async () => {} }); + + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'lookup_failed'); +}); + +const TV_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'productImage', type: 'image', value: '' }, + { name: 'oldPrice', type: 'text', value: '' }, + { name: 'price', type: 'text', value: '' }, + ], + }, + ], +}; + +test('actionEditGraphic rejects a price edit implying more than 40% off and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + recordEdits('phone-9', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-9', 'img_2', { price: 17000 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'discount_capped'); + assert.equal(result.maxPercent, 40); +}); + +test('actionEditGraphic applies a price edit at exactly the 40% cap (within rounding tolerance)', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 20399 }); + return { jobId: 'job-2', statusUrl: 'https://express-api.adobe.io/status/job-2' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb2.png' } }); + recordEdits('phone-10', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + + const result = await actionEditGraphic('phone-10', 'img_2', { price: 20399 }, { sendImage: async () => {} }); + + assert.equal(result.status, 'success'); + assert.deepEqual(result.changes, { price: 20399 }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npm test 2>&1 | grep -A3 "actionEditGraphic"` +Expected: FAIL — the updated/new tests fail because `editExpressDesign` still returns strings and has no discount-cap generalization yet. + +- [ ] **Step 4: Implement the generalized cap and structured outcomes in `src/actions.js`** + +Add these two helpers directly after the existing `parsePercent` function (after line 63): + +```js +const ROUNDING_TOLERANCE_PERCENT = 0.5; + +function findOldPriceKey(keys) { + return keys.find((key) => /^old.?price$/i.test(key)); +} + +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((key) => key !== oldPriceKey && /(^|_)price$/i.test(key)); +} + +// Covers both "discount ko 50% kar do" (GPT computes a price from oldPrice) and a +// direct "set price to X" request — either way, a price drop of more than the cap +// (plus rounding slack for whole-rupee prices) is rejected. +function impliesExcessiveDiscount(mergedEdits, requestedKeys) { + const keys = Object.keys(mergedEdits); + const oldPriceKey = findOldPriceKey(keys); + const newPriceKey = findNewPriceKey(keys, oldPriceKey); + if (!oldPriceKey || !newPriceKey || !requestedKeys.includes(newPriceKey)) return false; + + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + if (!Number.isFinite(oldPrice) || oldPrice <= 0 || !Number.isFinite(newPrice)) return false; + + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + return impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT; +} +``` + +Replace the whole `editExpressDesign` function (`src/actions.js:204-260`) with: + +```js +async function editExpressDesign(phoneNumber, image, edits, { sendImage }) { + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[editExpressDesign] Express API error', { docId: image.docId, message: err.message }); + return { status: 'api_error', productName: image.name, reason: 'lookup_failed' }; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return { + status: 'disallowed_fields', + productName: image.name, + disallowedKeys, + allowedSummary: expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; + } + + const oversizedDiscountKeys = requestedKeys.filter((key) => { + if (!isDiscountField(key)) return false; + const percent = parsePercent(edits[key]); + return percent !== null && percent > MAX_DISCOUNT_PERCENT; + }); + + const mergedEdits = { ...image.currentEdits, ...edits }; + + if (oversizedDiscountKeys.length > 0 || impliesExcessiveDiscount(mergedEdits, requestedKeys)) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; + } + + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); + } catch (err) { + console.error('[editExpressDesign] generate/poll error', { docId: image.docId, message: err.message }); + return { status: 'api_error', productName: image.name, reason: 'generate_failed' }; + } + + recordEdits(phoneNumber, image.id, edits); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[editExpressDesign] sendImage error', { docId: image.docId, message: err.message }); + return { status: 'delivery_failed', productName: image.name, changes: edits }; + } + + return { status: 'success', productName: image.name, changes: edits }; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 6: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Generalize discount cap to price edits and return structured edit outcomes" +``` + +--- + +### Task 2: Fixed Edit Product/Discount/Price menu for Express-catalog images + +**Files:** +- Modify: `src/actions.js:144-175` (`actionCheckAllowedEdits`) +- Modify: `src/actions.js:268-275` (`module.exports`) +- Test: `src/actions.test.js:1-73` (remove 2 obsolete tests, add 2 new ones) + +**Interfaces:** +- Consumes: `findTrackedImage`, `parseEditOptionId` (test-only import), `expressApi.buildEditOptions`/`formatAllowedEdits` (unchanged, still used for the local-design branch only). +- Produces: `buildTopLevelEditOptions(imageId)` → `{ type: 'edit_options', bodyText: 'What would you like to change?', options: [{id, title}, ...], historyText }`, newly exported from `actions.js`. `actionCheckAllowedEdits` returns this directly for `source: 'express'` images (no Express API call), and its previous local-design branch is unchanged. + +- [ ] **Step 1: Update `src/actions.test.js` — remove obsolete Express-branch tests, add new fixed-menu tests** + +Delete these three tests entirely (lines 29-72): `'actionCheckAllowedEdits lists the tagged elements for a known image'`, `'actionCheckAllowedEdits shows the latest edited value instead of the stale original document value'`, `'actionCheckAllowedEdits returns a friendly message when the Express API call fails'`. (They tested behavior — inspecting the real tagged document to build the menu — that no longer exists for Express-catalog images once the menu is fixed. Task 1's `lookup_failed` test already covers the "Express API fails" case, but now on `actionEditGraphic`, not `actionCheckAllowedEdits`.) + +Keep `'actionCheckAllowedEdits reports unknown images without throwing'` (lines 55-61) unchanged. + +Add these two tests in its place, and update the import line at the top of the file to include `buildTopLevelEditOptions`: + +```js +const { actionCheckAllowedEdits, actionEditGraphic, actionSelectTvModel, buildTopLevelEditOptions } = require('./actions'); +``` + +```js +test('actionCheckAllowedEdits returns the fixed Edit Product/Discount/Price menu for an Express-catalog image, without calling the Express API', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.equal(reply.type, 'edit_options'); + assert.equal(reply.bodyText, 'What would you like to change?'); + assert.deepEqual(reply.options, [ + { id: 'edit:img_1:product', title: 'Edit Product' }, + { id: 'edit:img_1:discount', title: 'Edit Discount' }, + { id: 'edit:img_1:price', title: 'Edit Price' }, + ]); + assert.match(reply.historyText, /Edit Product/); +}); + +test('buildTopLevelEditOptions ids parse back to the "product"/"discount"/"price" bare fields', () => { + const { options } = buildTopLevelEditOptions('img_1'); + + assert.deepEqual( + options.map((option) => parseEditOptionId(option.id)), + [ + { imageId: 'img_1', fieldName: 'product' }, + { imageId: 'img_1', fieldName: 'discount' }, + { imageId: 'img_1', fieldName: 'price' }, + ] + ); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm test 2>&1 | grep -B2 -A6 "fixed Edit Product"` +Expected: FAIL — `buildTopLevelEditOptions` is not exported yet and `actionCheckAllowedEdits` still calls the Express API. + +- [ ] **Step 3: Implement `buildTopLevelEditOptions` and wire it into `actionCheckAllowedEdits`** + +Add above `actionCheckAllowedEdits` (before line 144): + +```js +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; + +function buildTopLevelEditOptions(imageId) { + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: TOP_LEVEL_EDIT_FIELDS.map(({ fieldName, title }) => ({ + id: `edit:${imageId}:${fieldName}`, + title, + })), + historyText: 'What would you like to change? (Edit Product / Edit Discount / Edit Price)', + }; +} +``` + +Replace the body of `actionCheckAllowedEdits` (`src/actions.js:144-175`) with: + +```js +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + console.log('[action:check_allowed_edits]', { phoneNumber, imageId, source: image?.source ?? 'not_found' }); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + if (image.source === 'express') { + return buildTopLevelEditOptions(imageId); + } + + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: expressApi.buildEditOptions(elements, imageId), + historyText: expressApi.formatAllowedEdits(image.name, elements), + }; +} +``` + +Add `buildTopLevelEditOptions` to `module.exports` (`src/actions.js:268-275`): + +```js +module.exports = { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 5: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Replace dynamic edit menu with fixed Edit Product/Discount/Price for Express-catalog images" +``` + +--- + +### Task 3: Product picker copy — "Which product do you want?" + "Choose product" button + +**Files:** +- Modify: `src/actions.js:177-186` (`actionSelectTvModel`) +- Test: `src/actions.test.js:152-178` (update one test) + +**Interfaces:** +- Consumes: `buildValueEditId` (unchanged), `TV_MODEL_TITLES`, `TV_MODEL_EDITS` (unchanged constants). +- Produces: `actionSelectTvModel(imageId)` now also returns `buttonText: 'Choose product'`, and `bodyText` changes from `'Which model would you like to use?'` to `'Which product do you want?'`. This `buttonText` field is what Task 4's `sendEditOptions` uses to decide to send a WhatsApp list instead of buttons. + +- [ ] **Step 1: Update the existing test** + +Replace `'actionSelectTvModel returns the 3 fixed TV model options with the question body text'` (`src/actions.test.js:152-162`): + +```js +test('actionSelectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { + const result = actionSelectTvModel('img_1'); + + assert.equal(result.type, 'edit_options'); + assert.equal(result.bodyText, 'Which product do you want?'); + assert.equal(result.buttonText, 'Choose product'); + assert.equal(result.options.length, 3); + assert.deepEqual( + result.options.map((option) => option.title), + ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4'] + ); +}); +``` + +Leave `'actionSelectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id'` unchanged. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test 2>&1 | grep -A6 "list-picker body text"` +Expected: FAIL — actual `bodyText` is still `'Which model would you like to use?'` and `buttonText` is `undefined`. + +- [ ] **Step 3: Implement** + +Replace `actionSelectTvModel` (`src/actions.js:177-186`): + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', + options: TV_MODEL_TITLES.map((title) => ({ + id: buildValueEditId(imageId, TV_MODEL_EDITS), + title, + })), + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0`. + +- [ ] **Step 5: Commit** + +```bash +git add src/actions.js src/actions.test.js +git commit -m "Present TV model picker as a list with Choose product button" +``` + +--- + +### Task 4: WhatsApp list-message support in `app.js` + +**Files:** +- Modify: `src/app.js:83-96` (`sendEditOptions`, new `sendList`) + +**Interfaces:** +- Consumes: `whatsappPost` (existing), the `buttonText` field added to `actionSelectTvModel`'s result in Task 3. +- Produces: `sendList(to, { bodyText, buttonText, options })` — new. `sendEditOptions(to, result)` now branches: list when `result.buttonText` is present, otherwise the existing button-chunking behavior (used by the Task 2 fixed 3-option menu, which has no `buttonText`). + +No automated tests for this step — see Global Constraints (`app.js` isn't unit-testable without an `OPENAI_API_KEY` and starts a server on load). It's verified in Task 6's manual/simulated webhook check. + +- [ ] **Step 1: Add `sendList` and update `sendEditOptions`** + +Insert a new function directly after `sendButtons` (`src/app.js:81`, before the `BUTTONS_PER_MESSAGE` comment): + +```js +// WhatsApp list messages: a single "menu" button plus up to 10 rows in one section. +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} +``` + +Replace `sendEditOptions` (`src/app.js:87-96`): + +```js +async function sendEditOptions(to, result) { + const { bodyText, options, buttonText } = result; + if (options.length === 0) { + await sendText(to, bodyText); + return; + } + if (buttonText) { + await sendList(to, { bodyText, buttonText, options }); + return; + } + for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { + const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); + await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); + } +} +``` + +- [ ] **Step 2: Run the full test suite to confirm no regressions** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0` (this task touches no tested code paths; this just guards against a typo breaking something else). + +- [ ] **Step 3: Commit** + +```bash +git add src/app.js +git commit -m "Add WhatsApp list-message support for the product picker" +``` + +--- + +### Task 5: Dynamic, language-mirrored replies + bare-field GPT routing + menu resurfacing + +**Files:** +- Modify: `src/app.js:1-12` (imports) +- Modify: `src/app.js:210-247` (`decideAction` — images list, system prompt) +- Modify: `src/app.js:313-337` (dispatch: `check_allowed_edits` unaffected structurally, `edit_graphic` case rewritten) +- New: `phraseOutcome` function in `src/app.js` + +**Interfaces:** +- Consumes: `buildTopLevelEditOptions` (Task 2, imported from `./actions`), `actionEditGraphic`'s new return contract (Task 1: string for local, structured object for express), `image.currentEdits` (existing, from `getTrackedImages`). +- Produces: `phraseOutcome(phoneNumber, userMessage, outcome)` — new, not exported (only used within `app.js`'s dispatch). + +No automated tests for this step — see Global Constraints. Verified in Task 6. + +- [ ] **Step 1: Import `buildTopLevelEditOptions`** + +In `src/app.js:4-11`, change: + +```js +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, +} = require('./actions'); +``` + +to: + +```js +const { + actionListCampaignGraphics, + actionCreateDesign, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, + actionSelectTvModel, + buildTopLevelEditOptions, +} = require('./actions'); +``` + +- [ ] **Step 2: Show current field values in the images list, and add the bare-field routing + discount-computation rules to the system prompt** + +In `decideAction` (`src/app.js:210-247`), replace the `imagesList` line and the system prompt string. + +Replace: + +```js + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); +``` + +with: + +```js + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages + .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) + .join('\n'); +``` + +Add this helper function directly above `decideAction` (before line 210): + +```js +function formatCurrentEdits(currentEdits) { + const entries = Object.entries(currentEdits || {}); + if (entries.length === 0) return ''; + return ` (${entries.map(([key, value]) => `${key}: ${value}`).join(', ')})`; +} +``` + +In the system prompt template string (`src/app.js:218-229`), insert this new paragraph immediately before the final `Images previously sent to this user (reference by id):` line: + +``` +When the user taps a menu option for "product", "discount", or "price" (from the fixed Edit Product/Edit Discount/Edit Price menu on an Express-catalog graphic): +- "product": call select_tv_model. +- "discount" or "price" with no value given yet: call ask_for_more_information asking what they'd like the new discount or price to be. +- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with only { "price": } — never change oldPrice. +- "price" WITH a value: call edit_graphic with { "price": } directly, no computation needed. +``` + +- [ ] **Step 3: Add `phraseOutcome`** + +Add this function directly after `decideAction` (after line 247, before the `// ── Webhook routes ──` comment): + +```js +// Turns a structured edit outcome into the actual WhatsApp reply text, matching +// the user's language/style (English or Hinglish) rather than a fixed template. +async function phraseOutcome(phoneNumber, userMessage, outcome) { + const response = await openai.chat.completions.create({ + model: openaiModel, + messages: [ + { + role: 'system', + content: `You are a WhatsApp assistant. Given the outcome below, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. + +Examples of the tone/style to match: +- Success (English): "I have updated product, with price & discount" +- Success (Hinglish): "Maine discount aur price updated kar diya hai" +- Capped (Hinglish): "Iss product pr maximum 40% discount de sakte hain"`, + }, + { role: 'user', content: `User message: ${userMessage}\nOutcome: ${JSON.stringify(outcome)}` }, + ], + }); + + return response.choices[0].message.content; +} +``` + +- [ ] **Step 4: Rewrite the `edit_graphic` dispatch case** + +Replace the `case 'edit_graphic':` block (`src/app.js:334-337`): + +```js + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; +``` + +with: + +```js + case 'edit_graphic': { + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + if (typeof result === 'string') { + replyText = result; + } else { + replyText = await phraseOutcome(phoneNumber, userText, result); + await sendText(phoneNumber, replyText); + await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); + skipSend = true; + } + break; + } +``` + +- [ ] **Step 5: Run the full test suite to confirm no regressions** + +Run: `npm test 2>&1 | tail -20` +Expected: PASS — `# fail 0` (no test file exercises `app.js`, so this step is a syntax/regression guard for the rest of the suite). + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js +git commit -m "Add GPT-phrased, language-mirrored edit replies and bare-field discount/price routing" +``` + +--- + +### Task 6: Manual/simulated webhook verification of the full script + +**Files:** none (verification only) + +**Interfaces:** none — this task exercises the running app end-to-end via simulated webhook POST bodies, the same way prior features in this repo (e.g. the TV product-swap design) were manually verified before merge. + +- [ ] **Step 1: Start the app with required env vars** + +Run (fill in real values or test-double values for `WHATSAPP_TOKEN`/`OPENAI_API_KEY` per your local `.env` setup): + +```bash +npm start +``` + +Expected: `Listening on port 3000` with no startup errors. + +- [ ] **Step 2: Simulate each turn of the script as a webhook POST** + +For each user turn below, POST a WhatsApp-shaped webhook body to `http://localhost:3000/` (a `text` message for free-text turns, an `interactive.button_reply`/`interactive.list_reply` for tap turns) and confirm the described bot behavior in the server logs / connected WhatsApp test number: + +1. "I want to edit this" (text) → 3-button menu: Edit Product / Edit Discount / Edit Price. +2. Tap "Edit Product" → WhatsApp **list** message "Which product do you want?" with "Choose product" button, 3 rows (Sony Bravia K-75 / LG UA82 AI / Samsung UA4). +3. Tap "Sony Bravia K-75" → image updated to the Sony placeholder, oldPrice 33999 / price 27199 applied; phrased confirmation text sent; 3-button menu resent. +4. "discount ko 50% kar do" (text, Hinglish) → no image sent; a Hinglish denial mentioning the 40% cap; 3-button menu resent. +5. "Theek h, 40% hi kar do" (text, Hinglish) → image updated (price 20399, oldPrice unchanged 33999); Hinglish confirmation; 3-button menu resent. + +Expected: each step's bot behavior matches the description above. Exact reply wording will vary turn to turn (GPT-generated) — verify meaning and language register, not literal text. + +- [ ] **Step 3: Report results** + +If any step diverges from the expected behavior, note which step and the actual vs. expected outcome before proceeding — do not mark this task done with an unresolved mismatch. diff --git a/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md new file mode 100644 index 0000000000..78add2bad3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-product-discount-price-edit-flow-design.md @@ -0,0 +1,197 @@ +# Design: Fixed Edit Product/Discount/Price menu with Hinglish-mirrored replies + +## Goal + +Support this WhatsApp conversation flow for Express-catalog graphics (e.g. the Croma/TV template): + +1. User sends an edit message ("I want to edit this" / similar). +2. Bot shows "What would you like to change?" with 3 quick-reply buttons: **Edit Product**, **Edit Discount**, **Edit Price**. +3. User taps **Edit Product**. +4. Bot shows a WhatsApp **list** message: "Which product do you want?" with a "Choose product" button and the 3 TV models as rows. +5. User picks a model (e.g. Sony Bravia K-75). +6. Bot calls the Express API to set `productImage`, `oldPrice` (33999), `price` (27199) in one shot. +7. Bot sends the updated image, a phrased confirmation ("I have updated product, with price & discount"), and the 3-button menu again. +8. User asks for a discount in Hinglish ("discount ko 50% kar do"). +9. Bot computes the implied price, finds it exceeds the 40% cap, denies in Hinglish ("Iss product pr maximum 40% discount de sakte hain"), and shows the 3-button menu again. +10. User accepts the cap ("Theek h, 40% hi kar do"). +11. Bot computes price = 33999 × 0.6 = 20399, applies it, sends the updated image, a phrased Hinglish confirmation ("Maine discount aur price updated kar diya hai"), and the 3-button menu again. + +Scope: this replaces `check_allowed_edits`'s behavior **only for `source: 'express'` graphics** (the Express-catalog templates, e.g. the TV/Croma one). Local/Onam-style `source: 'local'` designs (background/address/headline edits — no product or price concept) are untouched and keep today's dynamic field-list menu. Editable field names on the real document are expected to be `productImage`, `oldPrice`, `price` as used today, though exact tag names may vary slightly later — see "Field name matching" below. + +## 1. Fixed top-level menu (reuses existing bare-field id scheme) + +`actionCheckAllowedEdits` branches on `image.source`: +- `'local'` → unchanged (today's dynamic field list from `localEditElements`). +- `'express'` → always returns 3 fixed options, using the existing bare-field id scheme from `interactiveReply.js` (`edit::`, **no changes needed to that module**): + +```js +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; +``` + +`bodyText`: `"What would you like to change?"` (fixed, English, never phrased by GPT — button/menu copy stays constant regardless of conversation language). + +Tapping a button produces the same kind of synthetic message as today, e.g. `"I'd like to change \"discount\" on image img_1."`, fed back through `decideAction`. + +### New system-prompt rules for these three bare fields + +- `"product"` → call `select_tv_model` (same tool used today for free-text "change product to tv"; only TV category exists, so no ambiguity). +- `"discount"` with no value yet, or `"price"` with no value yet → `ask_for_more_information` (existing bare-field behavior, unchanged). +- `"discount"` **with a value** (e.g. "50%", "50 percent", "discount ko 50% kar do") → GPT computes `newPrice = round(oldPrice × (1 − discountPercent/100))` using the `oldPrice` shown in the images list (see below), then calls `edit_graphic` with `{ price: newPrice }` only (never touches `oldPrice`). +- `"price"` **with a value** → call `edit_graphic` with `{ price: }` directly, no computation. + +## 2. Images list gains current field values + +`decideAction`'s system prompt `imagesList` is extended from `- : ` to include known current values pulled from `image.currentEdits` (already tracked in `imageStore`, no new storage): + +``` +- img_1: Croma Diwali offer (oldPrice: 33999, price: 27199) +``` + +This is what lets GPT compute a discount-derived price without an extra round trip to ask the user "what's the current price?". + +## 3. Product picker becomes a real WhatsApp list message + +`actionSelectTvModel` gains a `buttonText` field: + +```js +function actionSelectTvModel(imageId) { + return { + type: 'edit_options', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', + options: TV_MODEL_TITLES.map((title) => ({ id: buildValueEditId(imageId, TV_MODEL_EDITS), title })), + }; +} +``` + +New `sendList(to, { bodyText, buttonText, options })` in `app.js` builds a native list message: + +```js +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((o) => ({ id: o.id, title: o.title })) }], + }, + }, + }); +} +``` + +`sendEditOptions` picks list-vs-buttons based on whether `buttonText` is present on the result: + +```js +async function sendEditOptions(to, result) { + if (result.options.length === 0) return sendText(to, result.bodyText); + if (result.buttonText) return sendList(to, result); + // ...existing button-chunking loop, unchanged +} +``` + +The fixed 3-button top-level menu (part 1) has no `buttonText`, so it keeps using buttons as today — only the product picker becomes a list. Model titles stay as they are today: Sony Bravia K-75 / LG UA82 AI / Samsung UA4. LG and Samsung continue to share Sony's placeholder image/price (existing, intentional scoping from the prior TV-swap design — not changed here). + +Row `id`s reuse `buildValueEditId` exactly as today, so tapping a list row parses identically to tapping a button (`message?.interactive?.list_reply` is already read defensively in `app.js`). + +## 4. Generalized 40% discount cap (server-enforced, not GPT-trusted) + +Today's cap (`MAX_DISCOUNT_PERCENT = 40`, `editExpressDesign`) only fires on a literal field matching `/discount/i`. Since GPT now computes and submits a plain `price`, this needs to generalize: whenever the edits (merged with `image.currentEdits`) contain both an "old price" field and a "new price" field, compute the implied discount and reject if it exceeds the cap — regardless of whether the user phrased it as a discount % or a direct price. + +### Field name matching + +To tolerate minor future naming variation (per your note), matching is regex-based rather than exact-string: +- Reference/original price: `/^old.?price$/i` (matches `oldPrice`, `old_price`). +- New/selling price: `/(^|_)price$/i`, excluding whatever matched as the reference price above. + +```js +function findOldPriceKey(keys) { + return keys.find((k) => /^old.?price$/i.test(k)); +} +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((k) => k !== oldPriceKey && /(^|_)price$/i.test(k)); +} +``` + +In `editExpressDesign`, after merging `edits` into `image.currentEdits`: + +```js +const mergedEdits = { ...image.currentEdits, ...edits }; +const oldPriceKey = findOldPriceKey(Object.keys(mergedEdits)); +const newPriceKey = findNewPriceKey(Object.keys(mergedEdits), oldPriceKey); +if (oldPriceKey && newPriceKey && requestedKeys.includes(newPriceKey)) { + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + const ROUNDING_TOLERANCE_PERCENT = 0.5; + if (impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; + } +} +``` + +This replaces the existing `oversizedDiscountKeys` literal-field check (kept as a fallback for any future literal `discount_text`-style field, unchanged logic there). + +Boundary case: exactly 40% is allowed. Note that rounding the computed price to a whole number introduces sub-percent noise — e.g. 33999 × 0.6 = 20399.4, and whichever way GPT rounds that (20399 or 20400), the *implied* discount off the resulting whole-rupee price is 40.0012% or 39.9988%, never exactly 40.0000%. The cap check therefore compares against `MAX_DISCOUNT_PERCENT + 0.5` (a half-point rounding tolerance), not a strict `> 40`, so whole-rupee rounding on either side of the requested percentage never flips a legitimate at-cap request into a rejection. A genuinely excessive ask (e.g. 50%) is far outside this tolerance and still rejected. + +## 5. Structured outcomes + dynamic, language-mirrored replies + +`actionEditGraphic` (Express path) and `actionSelectTvModel`'s eventual `edit_graphic` call no longer return final hardcoded strings. They return a structured outcome: + +```js +{ status: 'success', productName, changes: { productImage, oldPrice, price, ... } } +{ status: 'discount_capped', productName, maxPercent: 40 } +{ status: 'disallowed_fields', productName, disallowedKeys, allowedSummary } +{ status: 'error', productName, reason } +``` + +New `phraseOutcome(phoneNumber, userMessage, outcome)` in `app.js`: a second, tool-free OpenAI chat completion call that turns the structured outcome into the actual WhatsApp reply text, matching the user's language style: + +> System prompt: "You are a WhatsApp assistant. Given this outcome, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. +> +> Examples of the tone/style to match: +> - Success (English): 'I have updated product, with price & discount' +> - Success (Hinglish): 'Maine discount aur price updated kar diya hai' +> - Capped (Hinglish): 'Iss product pr maximum 40% discount de sakte hain'" +> +> User message: `` +> Outcome: `` + +Its `content` becomes the `sendText` reply. This applies **only** to `edit_graphic`/`select_tv_model` outcomes on Express-catalog graphics — menu prompts and button/row titles stay fixed English strings always (never phrased by GPT), matching the script where the 3-button menu reappears in English even mid-Hinglish conversation. + +`phraseOutcome` isn't unit-tested for exact wording (it's an LLM call); the *outcome data* feeding into it is fully unit-tested instead. Manual/simulated webhook checks verify the reply is sensible and in the right language register. + +## 6. Post-edit menu resurfacing + +For Express-catalog graphics, every `edit_graphic`/`select_tv_model` outcome — success or capped/rejected — is followed by resending the fixed 3-button menu. Dispatch sequence in `app.js` for these actions: + +1. Run the action → get structured outcome. +2. If `status === 'success'`, `sendImage` the updated thumbnail. +3. `phraseOutcome(...)` → `sendText` the phrased reply. +4. `sendButtons(phoneNumber, 'What would you like to change?', TOP_LEVEL_EDIT_OPTIONS)` — always, regardless of outcome, for Express-catalog graphics only. + +Local/Onam designs keep their current single-reply behavior (no forced menu resend) — this step only fires when `image.source === 'express'`. + +## Testing + +- Unit tests for `findOldPriceKey`/`findNewPriceKey` and the generalized cap check: under cap, exactly at cap (40%, passes), over cap (rejected), missing old/new price keys (no-op, falls through to existing behavior). +- Unit tests for `sendList` payload shape (list type, button text, row ids/titles). +- Unit tests for `actionCheckAllowedEdits` on `source: 'express'` images returning the 3 fixed bare-field options; `source: 'local'` images unaffected. +- Unit tests for `actionSelectTvModel` including the new `buttonText` field. +- Existing `interactiveReply.test.js` coverage is unchanged (no new id scheme introduced). +- Manual/simulated webhook run through the full script: edit → product list → pick Sony → confirmation + menu → Hinglish discount request → capped denial + menu → accept 40% → success + menu. + +## Out of scope + +- Per-model distinct images/pricing for LG/Samsung (still shared placeholder, per existing TV-swap design). +- Any product category other than TV. +- Exact-wording assertions on GPT-phrased replies. +- Changes to `list_campaign_graphics`, `create_design`, or `generate_bulk_graphics` — untouched. diff --git a/src/actions.js b/src/actions.js index 17f186a094..82e179a6a6 100644 --- a/src/actions.js +++ b/src/actions.js @@ -31,6 +31,12 @@ function actionSelectTvModel(imageId) { return expressFlow.selectTvModel(imageId); } +// Flow 1 (express/catalog) — the fixed Edit Product/Discount/Price menu, reused +// by app.js to resurface the menu after every edit outcome on this flow. +function buildTopLevelEditOptions(imageId) { + return expressFlow.buildTopLevelEditOptions(imageId); +} + // Router: resolve the image, then hand off to the flow that owns it. async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); @@ -66,4 +72,5 @@ module.exports = { actionEditGraphic, actionGenerateBulkGraphics, actionSelectTvModel, + buildTopLevelEditOptions, }; diff --git a/src/app.js b/src/app.js index b6f246dfa2..4b97f515dd 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const { actionEditGraphic, actionGenerateBulkGraphics, actionSelectTvModel, + buildTopLevelEditOptions, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); @@ -81,15 +82,37 @@ function sendButtons(to, bodyText, options) { }); } +// WhatsApp list messages: a single "menu" button plus up to 10 rows in one section. +function sendList(to, { bodyText, buttonText, options }) { + return whatsappPost({ + messaging_product: 'whatsapp', + to, + type: 'interactive', + interactive: { + type: 'list', + body: { text: bodyText }, + action: { + button: buttonText, + sections: [{ rows: options.map((option) => ({ id: option.id, title: option.title })) }], + }, + }, + }); +} + // WhatsApp reply-button messages cap out at 3 buttons, so options beyond that // go out as additional button messages rather than falling back to a list picker. const BUTTONS_PER_MESSAGE = 3; -async function sendEditOptions(to, { bodyText, options }) { +async function sendEditOptions(to, result) { + const { bodyText, options, buttonText } = result; if (options.length === 0) { await sendText(to, bodyText); return; } + if (buttonText) { + await sendList(to, { bodyText, buttonText, options }); + return; + } for (let i = 0; i < options.length; i += BUTTONS_PER_MESSAGE) { const chunk = options.slice(i, i + BUTTONS_PER_MESSAGE); await sendButtons(to, i === 0 ? bodyText : 'More edits:', chunk); @@ -224,12 +247,20 @@ const tools = [ // ── GPT decision engine ────────────────────────────────────────────────────── +function formatCurrentEdits(currentEdits) { + const entries = Object.entries(currentEdits || {}); + if (entries.length === 0) return ''; + return ` (${entries.map(([key, value]) => `${key}: ${value}`).join(', ')})`; +} + async function decideAction(phoneNumber, userMessage) { // Keep a wide window so multi-step flows (e.g. "design X" → "Onam? yes" → // "address? yes" → create) still see the original product/offer request. const recentHistory = getHistory(phoneNumber).slice(-12); const trackedImages = getTrackedImages(phoneNumber); - const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + const imagesList = trackedImages + .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) + .join('\n'); const messages = [ { @@ -248,6 +279,11 @@ Creating a brand-new design (create_design), gather details first with tappable Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. +When the user taps a menu option for "product", "discount", or "price" (from the fixed Edit Product/Edit Discount/Edit Price menu on an Express-catalog graphic): +- "product": call select_tv_model. +- "discount" or "price" with no value given yet: call ask_for_more_information asking what they'd like the new discount or price to be. +- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with only { "price": } — never change oldPrice. +- "price" WITH a value: call edit_graphic with { "price": } directly, no computation needed. Images previously sent to this user (reference by id): ${imagesList}`, @@ -270,6 +306,28 @@ ${imagesList}`, return response.choices[0].message; } +// Turns a structured edit outcome into the actual WhatsApp reply text, matching +// the user's language/style (English or Hinglish) rather than a fixed template. +async function phraseOutcome(phoneNumber, userMessage, outcome) { + const response = await openai.chat.completions.create({ + model: openaiModel, + messages: [ + { + role: 'system', + content: `You are a WhatsApp assistant. Given the outcome below, write a short reply to the user. Match the user's language and style — if their message was Hinglish (romanized Hindi mixed with English), reply in Hinglish; otherwise reply in English. Don't invent facts beyond the outcome given. + +Examples of the tone/style to match: +- Success (English): "I have updated product, with price & discount" +- Success (Hinglish): "Maine discount aur price updated kar diya hai" +- Capped (Hinglish): "Iss product pr maximum 40% discount de sakte hain"`, + }, + { role: 'user', content: `User message: ${userMessage}\nOutcome: ${JSON.stringify(outcome)}` }, + ], + }); + + return response.choices[0].message.content; +} + // ── Webhook routes ─────────────────────────────────────────────────────────── app.get('/', (req, res) => { @@ -370,11 +428,30 @@ app.post('/', async (req, res) => { } case 'edit_graphic': { - // Progress is streamed from inside the flow. On success the flow already - // sent the image+caption; a guardrail rejection returns a plain string. + // Progress is streamed from inside the flow. Local-flow outcomes are + // either a plain string (guardrail rejection) or {skipSend:true, + // historyText} (success, image+caption already sent). Express-flow + // outcomes are always a structured {status, ...} object — phrased here + // to match the user's language, delivered with that phrasing as the + // image caption, then followed by the fixed Edit Product/Discount/Price menu. const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); if (typeof result === 'string') { replyText = result; + } else if (result.status) { + replyText = await phraseOutcome(phoneNumber, userText, result); + if (result.status === 'success') { + try { + await sendImage(phoneNumber, result.thumbnailUrl, replyText); + } catch (err) { + console.error('[edit_graphic] sendImage error', { message: err.message }); + replyText = `Updated "${result.productName}", but I couldn't send the image right now — try asking me to resend it.`; + await sendText(phoneNumber, replyText); + } + } else { + await sendText(phoneNumber, replyText); + } + await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); + skipSend = true; } else { replyText = result.historyText; skipSend = true; diff --git a/src/expressFlow.js b/src/expressFlow.js index 10e4a24ccd..00dc6199ab 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -1,6 +1,7 @@ // ── Flow 1: Adobe Express-backed catalog designs (image.source === 'express') ─ // Real Adobe Express API: read the tagged document, validate edits against the -// live tagged elements, generate a variation, poll, and send the rendered image. +// live tagged elements, generate a variation, poll, and hand back a structured +// outcome for the caller (app.js) to phrase, deliver, and follow up on. // // Self-contained: this module must NOT depend on the local/canned flow. It is // reached only via the router in actions.js for images whose source is 'express'. @@ -8,7 +9,7 @@ const { recordEdits } = require('./imageStore'); const expressApi = require('./express/expressApi'); const { buildValueEditId } = require('./interactiveReply'); -const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); +const { formatAllowedEdits } = require('./editOptions'); // A "change the product to a TV" request offers 3 fixed models as quick replies. const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; @@ -16,6 +17,16 @@ const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; const MAX_DISCOUNT_PERCENT = 40; +const ROUNDING_TOLERANCE_PERCENT = 0.5; + +// Express-catalog graphics always show this fixed 3-option menu rather than a +// per-document field list; tapping one produces a bare "product"/"discount"/ +// "price" field id via the existing interactive-reply scheme (interactiveReply.js). +const TOP_LEVEL_EDIT_FIELDS = [ + { fieldName: 'product', title: 'Edit Product' }, + { fieldName: 'discount', title: 'Edit Discount' }, + { fieldName: 'price', title: 'Edit Price' }, +]; function withCurrentEdits(elements, currentEdits) { return elements.map((element) => @@ -32,12 +43,39 @@ function parsePercent(value) { return match ? Number(match[1]) : null; } +function findOldPriceKey(keys) { + return keys.find((key) => /^old.?price$/i.test(key)); +} + +function findNewPriceKey(keys, oldPriceKey) { + return keys.find((key) => key !== oldPriceKey && /(^|_)price$/i.test(key)); +} + +// Covers both "discount ko 50% kar do" (GPT computes a price from oldPrice) and a +// direct "set price to X" request — either way, a price drop of more than the cap +// (plus rounding slack for whole-rupee prices) is rejected. +function impliesExcessiveDiscount(mergedEdits, requestedKeys) { + const keys = Object.keys(mergedEdits); + const oldPriceKey = findOldPriceKey(keys); + const newPriceKey = findNewPriceKey(keys, oldPriceKey); + if (!oldPriceKey || !newPriceKey || !requestedKeys.includes(newPriceKey)) return false; + + const oldPrice = Number(mergedEdits[oldPriceKey]); + const newPrice = Number(mergedEdits[newPriceKey]); + if (!Number.isFinite(oldPrice) || oldPrice <= 0 || !Number.isFinite(newPrice)) return false; + + const impliedDiscountPercent = ((oldPrice - newPrice) / oldPrice) * 100; + return impliedDiscountPercent > MAX_DISCOUNT_PERCENT + ROUNDING_TOLERANCE_PERCENT; +} + // TV model picker — each option id encodes the full productImage/price edits so a // tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). +// Presented as a WhatsApp list (buttonText set) rather than reply buttons. function selectTvModel(imageId) { return { type: 'edit_options', - bodyText: 'Which model would you like to use?', + bodyText: 'Which product do you want?', + buttonText: 'Choose product', options: TV_MODEL_TITLES.map((title) => ({ id: buildValueEditId(imageId, TV_MODEL_EDITS), title, @@ -45,33 +83,36 @@ function selectTvModel(imageId) { }; } -// What can be edited? — reads the live tagged document from Adobe Express. +function buildTopLevelEditOptions(imageId) { + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: TOP_LEVEL_EDIT_FIELDS.map(({ fieldName, title }) => ({ + id: `edit:${imageId}:${fieldName}`, + title, + })), + historyText: 'What would you like to change? (Edit Product / Edit Discount / Edit Price)', + }; +} + +// What can be edited? — the menu is fixed and doesn't depend on document +// contents, so no Express API call is needed here. async function checkAllowedEdits(image) { - try { - const doc = await expressApi.getTaggedDocument(image.docId); - const elements = expressApi.collectTaggedElements(doc); - const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: buildEditOptions(elementsWithCurrentEdits, image.id), - historyText: formatAllowedEdits(image.name, elementsWithCurrentEdits), - }; - } catch (err) { - console.error('[expressFlow.checkAllowedEdits] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; - } + return buildTopLevelEditOptions(image.id); } -// Apply edits via the real Adobe Express generate-variation pipeline. -async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { +// Apply edits via the real Adobe Express generate-variation pipeline. Returns a +// structured outcome — never sends the image itself — so the caller (app.js) can +// phrase the final reply (matching the user's language) and deliver the image +// with that phrasing as its caption in one message. +async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); elements = expressApi.collectTaggedElements(doc); } catch (err) { console.error('[expressFlow.editGraphic] Express API error', { docId: image.docId, message: err.message }); - return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + return { status: 'api_error', productName: image.name, reason: 'lookup_failed' }; } const allowedNames = elements.map((element) => element.name); @@ -80,7 +121,12 @@ async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { if (disallowedKeys.length > 0) { const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); - return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + return { + status: 'disallowed_fields', + productName: image.name, + disallowedKeys, + allowedSummary: formatAllowedEdits(image.name, elementsWithCurrentEdits), + }; } const oversizedDiscountKeys = requestedKeys.filter((key) => { @@ -89,13 +135,14 @@ async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { return percent !== null && percent > MAX_DISCOUNT_PERCENT; }); - if (oversizedDiscountKeys.length > 0) { - return `The maximum discount I can apply on "${image.name}" is ${MAX_DISCOUNT_PERCENT}%. Try again with ${MAX_DISCOUNT_PERCENT}% or less.`; + const mergedEdits = { ...image.currentEdits, ...edits }; + + if (oversizedDiscountKeys.length > 0 || impliesExcessiveDiscount(mergedEdits, requestedKeys)) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; } if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); - const mergedEdits = { ...image.currentEdits, ...edits }; const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); @@ -107,23 +154,18 @@ async function editGraphic(phoneNumber, image, edits, { sendImage, sendText }) { console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); } catch (err) { console.error('[expressFlow.editGraphic] generate/poll error', { docId: image.docId, message: err.message }); - return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + return { status: 'api_error', productName: image.name, reason: 'generate_failed' }; } recordEdits(phoneNumber, image.id, edits); - const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); - // Caption carries the summary so text never arrives before the image. - const caption = `Updated "${image.name}":\n${summary}\n\nAnything else you'd like to change?`; - - try { - await sendImage(phoneNumber, thumbnailUrl, caption); - } catch (err) { - console.error('[expressFlow.editGraphic] sendImage error', { docId: image.docId, message: err.message }); - return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; - } - - return { skipSend: true, historyText: caption }; + return { status: 'success', productName: image.name, changes: edits, thumbnailUrl }; } -module.exports = { selectTvModel, checkAllowedEdits, editGraphic, MAX_DISCOUNT_PERCENT }; +module.exports = { + selectTvModel, + checkAllowedEdits, + editGraphic, + buildTopLevelEditOptions, + MAX_DISCOUNT_PERCENT, +}; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 26e091b573..8492d67a13 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -26,66 +26,70 @@ const SAMPLE_ELEMENTS_DOC = { ], }; +const TV_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'productImage', type: 'image', value: '' }, + { name: 'oldPrice', type: 'text', value: '' }, + { name: 'price', type: 'text', value: '' }, + ], + }, + ], +}; + // A catalog (source: 'express') image resolved through imageStore. function catalogImage(phone) { writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); return findTrackedImage(phone, 'img_1'); } -test('checkAllowedEdits lists the tagged elements for a known image', async () => { - expressApi.getTaggedDocument = async (docId) => { - assert.equal(docId, 'urn:doc:1'); - return SAMPLE_ELEMENTS_DOC; - }; +test('checkAllowedEdits returns the fixed Edit Product/Discount/Price menu, without calling the Express API', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; const image = catalogImage('phone-1'); const reply = await expressFlow.checkAllowedEdits(image); - assert.match(reply.historyText, /Croma Earbuds/); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); - assert.match(reply.historyText, /cta: currently/); -}); - -test('checkAllowedEdits shows the latest edited value instead of the stale document value', async () => { - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); - recordEdits('phone-1b', 'img_1', { cta: '20% off' }); - const image = findTrackedImage('phone-1b', 'img_1'); - - const reply = await expressFlow.checkAllowedEdits(image); - - assert.match(reply.historyText, /cta: currently "20% off"/); - assert.doesNotMatch(reply.historyText, /Available at our store starting 15 Aug 20XX\./); - assert.match(reply.historyText, /heading: currently "The X-Phone Pro is here!"/); + assert.equal(reply.type, 'edit_options'); + assert.equal(reply.bodyText, 'What would you like to change?'); + assert.deepEqual(reply.options, [ + { id: 'edit:img_1:product', title: 'Edit Product' }, + { id: 'edit:img_1:discount', title: 'Edit Discount' }, + { id: 'edit:img_1:price', title: 'Edit Price' }, + ]); + assert.match(reply.historyText, /Edit Product/); }); -test('checkAllowedEdits returns a friendly message when the Express API call fails', async () => { - expressApi.getTaggedDocument = async () => { - throw new Error('getTaggedDocument failed 500: boom'); - }; - const image = catalogImage('phone-3'); +test('buildTopLevelEditOptions ids parse back to the "product"/"discount"/"price" bare fields', () => { + const { options } = expressFlow.buildTopLevelEditOptions('img_1'); - const reply = await expressFlow.checkAllowedEdits(image); - - assert.match(reply, /couldn't check the allowed edits/); + assert.deepEqual( + options.map((option) => parseEditOptionId(option.id)), + [ + { imageId: 'img_1', fieldName: 'product' }, + { imageId: 'img_1', fieldName: 'discount' }, + { imageId: 'img_1', fieldName: 'price' }, + ] + ); }); -test('editGraphic rejects edits outside the tagged elements and makes no generate call', async () => { +test('editGraphic returns a disallowed_fields status and makes no generate call for a field outside the tagged elements', async () => { expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async () => { throw new Error('should not be called'); }; - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; const image = catalogImage('phone-4'); - const reply = await expressFlow.editGraphic('phone-4', image, { background_color: 'red' }, { sendImage }); + const result = await expressFlow.editGraphic('phone-4', image, { background_color: 'red' }, {}); - assert.match(reply, /can't edit background_color/); - assert.equal(sendImageCalled, false); + assert.equal(result.status, 'disallowed_fields'); + assert.equal(result.productName, 'Croma Earbuds'); + assert.deepEqual(result.disallowedKeys, ['background_color']); + assert.match(result.allowedSummary, /Edits allowed on "Croma Earbuds"/); }); -test('editGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { +test('editGraphic applies an allowed edit end-to-end: generates, polls, and returns a success outcome with the thumbnail', async () => { expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { assert.equal(docId, 'urn:doc:1'); @@ -98,61 +102,81 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, sends th assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; }; - - const sentCalls = []; - const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; const image = catalogImage('phone-5'); - const reply = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, { sendImage }); + const result = await expressFlow.editGraphic('phone-5', image, { cta: '20% off' }, {}); - assert.match(reply.historyText, /Updated "Croma Earbuds"/); - assert.equal(sentCalls.length, 1); - assert.equal(sentCalls[0].to, 'phone-5'); - assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: { cta: '20% off' }, + thumbnailUrl: 'https://example.com/thumb.png', + }); const updated = findTrackedImage('phone-5', 'img_1'); assert.deepEqual(updated.currentEdits, { cta: '20% off' }); }); -test('editGraphic returns a friendly message and does not record the edit when generation fails', async () => { +test('editGraphic returns an api_error/generate_failed status and does not record the edit when generation fails', async () => { expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; - - let sendImageCalled = false; - const sendImage = async () => { sendImageCalled = true; }; const image = catalogImage('phone-6'); - const reply = await expressFlow.editGraphic('phone-6', image, { cta: '20% off' }, { sendImage }); + const result = await expressFlow.editGraphic('phone-6', image, { cta: '20% off' }, {}); - assert.match(reply, /something went wrong generating/); - assert.equal(sendImageCalled, false); + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'generate_failed'); const updated = findTrackedImage('phone-6', 'img_1'); assert.deepEqual(updated.currentEdits, {}); }); -test('editGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { - expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; - expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); - expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); +test('editGraphic returns an api_error/lookup_failed status when getTaggedDocument fails', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('getTaggedDocument failed 500: boom'); }; + const image = catalogImage('phone-8'); - const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; - const image = catalogImage('phone-7'); + const result = await expressFlow.editGraphic('phone-8', image, { cta: '20% off' }, {}); - const reply = await expressFlow.editGraphic('phone-7', image, { cta: '20% off' }, { sendImage }); + assert.equal(result.status, 'api_error'); + assert.equal(result.reason, 'lookup_failed'); +}); - assert.match(reply, /couldn't send the image right now/); - assert.doesNotMatch(reply, /something went wrong generating/); +test('editGraphic rejects a price edit implying more than 40% off and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + recordEdits('phone-9', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const image = findTrackedImage('phone-9', 'img_2'); - const updated = findTrackedImage('phone-7', 'img_1'); - assert.deepEqual(updated.currentEdits, { cta: '20% off' }); + const result = await expressFlow.editGraphic('phone-9', image, { price: 17000 }, {}); + + assert.equal(result.status, 'discount_capped'); + assert.equal(result.maxPercent, 40); +}); + +test('editGraphic applies a price edit at exactly the 40% cap (within rounding tolerance)', async () => { + writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 20399 }); + return { jobId: 'job-2', statusUrl: 'https://express-api.adobe.io/status/job-2' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb2.png' } }); + recordEdits('phone-10', 'img_2', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const image = findTrackedImage('phone-10', 'img_2'); + + const result = await expressFlow.editGraphic('phone-10', image, { price: 20399 }, {}); + + assert.equal(result.status, 'success'); + assert.deepEqual(result.changes, { price: 20399 }); }); -test('selectTvModel returns the 3 fixed TV model options with the question body text', () => { +test('selectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { const result = expressFlow.selectTvModel('img_1'); assert.equal(result.type, 'edit_options'); - assert.equal(result.bodyText, 'Which model would you like to use?'); + assert.equal(result.bodyText, 'Which product do you want?'); + assert.equal(result.buttonText, 'Choose product'); assert.equal(result.options.length, 3); assert.deepEqual( result.options.map((option) => option.title), From e0a820f5e56d87f34eef8a8728f98ed7735b59ff Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 01:20:44 +0530 Subject: [PATCH 21/38] fix(local): give each TV model list row a unique id selectTvModel encoded the same imageId + shared TV_MODEL_EDITS into every option id, so all 3 WhatsApp list rows (Sony/LG/Samsung) got an identical id. WhatsApp rejects list messages with duplicate row ids (400 #131009), so the picker never reached the user. buildValueEditId now accepts an optional discriminator (the model title) so ids stay unique while still resolving to the same edits on tap. Co-Authored-By: Claude Sonnet 5 --- src/expressFlow.js | 6 ++++-- src/expressFlow.test.js | 7 +++++++ src/interactiveReply.js | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index 00dc6199ab..18bd8695ba 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -69,7 +69,9 @@ function impliesExcessiveDiscount(mergedEdits, requestedKeys) { } // TV model picker — each option id encodes the full productImage/price edits so a -// tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). +// tap tells GPT exactly what to apply (see interactiveReply.buildValueEditId). All 3 +// models share the same placeholder edits, so the title is passed as a discriminator +// to keep the 3 row ids unique — WhatsApp rejects list messages with duplicate row ids. // Presented as a WhatsApp list (buttonText set) rather than reply buttons. function selectTvModel(imageId) { return { @@ -77,7 +79,7 @@ function selectTvModel(imageId) { bodyText: 'Which product do you want?', buttonText: 'Choose product', options: TV_MODEL_TITLES.map((title) => ({ - id: buildValueEditId(imageId, TV_MODEL_EDITS), + id: buildValueEditId(imageId, TV_MODEL_EDITS, title), title, })), }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 8492d67a13..785d0ea99c 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -184,6 +184,13 @@ test('selectTvModel returns the 3 fixed TV model options with a list-picker body ); }); +test('selectTvModel gives every option a unique id (WhatsApp list rows must not repeat ids)', () => { + const result = expressFlow.selectTvModel('img_1'); + + const ids = result.options.map((option) => option.id); + assert.equal(new Set(ids).size, ids.length); +}); + test('selectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { const result = expressFlow.selectTvModel('img_1'); diff --git a/src/interactiveReply.js b/src/interactiveReply.js index d7aa55a63f..09d0548fbf 100644 --- a/src/interactiveReply.js +++ b/src/interactiveReply.js @@ -4,8 +4,17 @@ const EDIT_ID_PREFIX = 'edit:'; // see expressApi.buildEditOptions) or `edit:${imageId}:${encodeURIComponent(JSON.stringify(edits))}` // (fully-specified — see actions.actionSelectTvModel) — parse either shape back out so // a tap can tell GPT exactly what to do instead of only the truncated button title. -function buildValueEditId(imageId, edits) { - return `${EDIT_ID_PREFIX}${imageId}:${encodeURIComponent(JSON.stringify(edits))}`; +// +// An optional discriminator can be inserted before the encoded edits (imageId:discriminator:json) +// so that multiple options sharing the exact same edits (e.g. selectTvModel's 3 models, which all +// apply the same placeholder productImage/price) still get distinct ids — WhatsApp list rows must +// have unique ids or the whole message is rejected (400 #131009 "Duplicated row id"). The +// discriminator itself carries no meaning to the parser; it's discarded on parse. +function buildValueEditId(imageId, edits, discriminator) { + const encoded = encodeURIComponent(JSON.stringify(edits)); + return discriminator === undefined + ? `${EDIT_ID_PREFIX}${imageId}:${encoded}` + : `${EDIT_ID_PREFIX}${imageId}:${discriminator}:${encoded}`; } function parseEditOptionId(id) { @@ -14,7 +23,9 @@ function parseEditOptionId(id) { const separatorIndex = rest.indexOf(':'); if (separatorIndex === -1) return null; const imageId = rest.slice(0, separatorIndex); - const remainder = rest.slice(separatorIndex + 1); + // The encoded edits/field segment is always the LAST colon-separated part (encodeURIComponent + // never leaves a raw ':' in it), so a discriminator inserted in between is simply skipped over. + const remainder = rest.slice(rest.lastIndexOf(':') + 1); try { const decoded = JSON.parse(decodeURIComponent(remainder)); From 6406aa53d32f39659153e0d89fd4432af2c76488 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 02:01:08 +0530 Subject: [PATCH 22/38] fix(express): use S3 URL for TV placeholder image, not Scene7 Adobe's generate-variation API only accepts pre-signed image URLs from AWS/Dropbox/Azure (windows.net) for tagMappings; the Scene7 CDN URL was rejected, causing the Sony Bravia edit to fail against the real Express API. Also export the constant so the test doesn't hardcode a copy that'll drift when the URL is next rotated. Co-Authored-By: Claude Sonnet 5 --- src/expressFlow.js | 10 +++++++++- src/expressFlow.test.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index 18bd8695ba..4cedf0683b 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -12,7 +12,14 @@ const { buildValueEditId } = require('./interactiveReply'); const { formatAllowedEdits } = require('./editOptions'); // A "change the product to a TV" request offers 3 fixed models as quick replies. -const TV_PLACEHOLDER_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +// +// Must be a pre-signed URL on a domain Adobe's generate-variation API accepts for +// image tagMappings — AWS S3, Dropbox, or Azure (windows.net) only (see +// VariationDetails.tagMappings in the Express API spec). A Scene7 CDN URL was used +// here previously and Adobe rejected it, since scene7.com isn't an allowed domain. +// This S3 URL is itself pre-signed and expires (~12h from generation on 2026-07-22) +// — it will need to be regenerated/replaced before then to keep working. +const TV_PLACEHOLDER_IMAGE_URL = 'https://pmodi2.s3.us-west-1.amazonaws.com/SonyTv.png?response-content-disposition=inline&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBUaCXVzLXdlc3QtMSJHMEUCIAR%2BhaDDo11C4l%2BTaARgAfjNmrzEI4Odss6xvwmkN7pSAiEA8WvY0XqBgrvf97l8oq2vXo9wzPcVOTB%2FmhhOljmHp%2FgqhQQI3v%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw3ODU4OTAyNjg3MjQiDAa0WPLo6XDtvyGwYSrZA%2F91LMmqkGuC97Gp1YGw35bNLB1ci0qtqw8DOy%2BNsRyehXLhxaN3H5uifrQunTBrfC9jYEt5IGonDgnatWKi3rSOO2%2BPioo7FamZyIbroeniI%2BMy8mdV9wYCHQweXb3w6YD2eGxAXvDUxGLMnDL60ZAZ4DrcL5o%2BmtMOMvQi6brBdODM5k8YxRDMhnBb1gT4h%2FVuBO67na7LdNwDnx%2BY7Q4Dl4xbYHbrieEl9FRXHk%2Fd4v4rVWCVynJPgmL7m%2B4qwmKJjfX4aeHFt8criBiJzqcaTJdL1UzZsIeqf3icwvHIabvlmCigoHBBLykfuRm6HLkY1onUoh1z0YC5otVgQmss0nz73L4jHwaKIQSLgQDm%2B%2BUwfljiYfz1A8Pfbf5OObziWY%2F4L11qqJzrE0QPanFPaUGdZHbsxBz88JhHtUos61sZ4CPWMrpgLYElxupxegfzE45MXTWqWzIHoqPQlok%2B5137knQLH1VzLoTlS%2BeWg5jADKIWARAbOz2SeaXQ9GoIyZvFVK0%2FJSk1FeWdLRnrabUY%2Bp%2Ba0df6n%2BaS1fQdW2baqPbE%2FzSXOYplregbzUNrPimfWVyb%2BFLt3q5D2qFpAql4BVyZQdH6cT6PNMytziUIkkCZSO6yMOLLhNMGOrcCOM8ML%2FbFF6E9GCVwCIDLfS89AoYr56a9l%2B9FaySJurH%2Fp9hwJ9TvlbLMxZObFZ8LenJhGuk77S%2FlJ2XIl3kpnDkLvmLCP%2BY3ivrmiQxnJigA4k4PiA0cosefbL%2BrzkxiPzUz%2FEh5owO84yCDpGwHlcyz6FggHY8DZY8CNcHnOPG9WWLwl54vxUfsrQ336hzyFQu5Qv1JvXi8MXWexWXziP%2BUemQY5HIUpw2IzrD%2Fl5FDR2KV5TwNhx8RhFJd1YOoi9eJwSyIh2486KVNxLyvkpWLsELNbYfWKo%2Fk7kmWGsTVHzK6I2FWEjRUvwRC2hzilUmMfb08QFdcBAohTW6fc%2BiEzRr1abFFLFKXCxxPnDXEhNltBC3FBYyu%2BXrIK6bqglLBgftc7LbSa1w1HnsLd7iEOqgaRL8%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIA3N6VV2Y2GFNDGC7T%2F20260722%2Fus-west-1%2Fs3%2Faws4_request&X-Amz-Date=20260722T202201Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=95c3bb1f62907709e9d559fb3c6dd6ab1467f85044c74671a9d071a7cf31e199'; const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; @@ -170,4 +177,5 @@ module.exports = { editGraphic, buildTopLevelEditOptions, MAX_DISCOUNT_PERCENT, + TV_PLACEHOLDER_IMAGE_URL, }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 785d0ea99c..f3fab68d22 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -199,7 +199,7 @@ test('selectTvModel encodes the same fixed productImage/oldPrice/price edits int assert.deepEqual(parsed, { imageId: 'img_1', edits: { - productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', + productImage: expressFlow.TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199, }, From 595512e3aa1c809441465415d1ff31f01608fad4 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 02:09:50 +0530 Subject: [PATCH 23/38] fix(express): shrink TV model row ids below WhatsApp's 200-char limit Swapping the placeholder image to a long pre-signed S3 URL made selectTvModel's row ids ~2000 chars (encoded edits round-trip through both the WhatsApp row id and the GPT-facing message text), which WhatsApp rejects with #131009 "Row id is too long. Max length is 200." Encode a short token in place of the real URL, and expand it back to the actual S3 URL in editGraphic right before it reaches the Express API, so nothing WhatsApp- or GPT-facing carries the full URL. Co-Authored-By: Claude Sonnet 5 --- src/expressFlow.js | 20 +++++++++++++++++++- src/expressFlow.test.js | 28 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index 4cedf0683b..b6d1c5b086 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -21,7 +21,22 @@ const { formatAllowedEdits } = require('./editOptions'); // — it will need to be regenerated/replaced before then to keep working. const TV_PLACEHOLDER_IMAGE_URL = 'https://pmodi2.s3.us-west-1.amazonaws.com/SonyTv.png?response-content-disposition=inline&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBUaCXVzLXdlc3QtMSJHMEUCIAR%2BhaDDo11C4l%2BTaARgAfjNmrzEI4Odss6xvwmkN7pSAiEA8WvY0XqBgrvf97l8oq2vXo9wzPcVOTB%2FmhhOljmHp%2FgqhQQI3v%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw3ODU4OTAyNjg3MjQiDAa0WPLo6XDtvyGwYSrZA%2F91LMmqkGuC97Gp1YGw35bNLB1ci0qtqw8DOy%2BNsRyehXLhxaN3H5uifrQunTBrfC9jYEt5IGonDgnatWKi3rSOO2%2BPioo7FamZyIbroeniI%2BMy8mdV9wYCHQweXb3w6YD2eGxAXvDUxGLMnDL60ZAZ4DrcL5o%2BmtMOMvQi6brBdODM5k8YxRDMhnBb1gT4h%2FVuBO67na7LdNwDnx%2BY7Q4Dl4xbYHbrieEl9FRXHk%2Fd4v4rVWCVynJPgmL7m%2B4qwmKJjfX4aeHFt8criBiJzqcaTJdL1UzZsIeqf3icwvHIabvlmCigoHBBLykfuRm6HLkY1onUoh1z0YC5otVgQmss0nz73L4jHwaKIQSLgQDm%2B%2BUwfljiYfz1A8Pfbf5OObziWY%2F4L11qqJzrE0QPanFPaUGdZHbsxBz88JhHtUos61sZ4CPWMrpgLYElxupxegfzE45MXTWqWzIHoqPQlok%2B5137knQLH1VzLoTlS%2BeWg5jADKIWARAbOz2SeaXQ9GoIyZvFVK0%2FJSk1FeWdLRnrabUY%2Bp%2Ba0df6n%2BaS1fQdW2baqPbE%2FzSXOYplregbzUNrPimfWVyb%2BFLt3q5D2qFpAql4BVyZQdH6cT6PNMytziUIkkCZSO6yMOLLhNMGOrcCOM8ML%2FbFF6E9GCVwCIDLfS89AoYr56a9l%2B9FaySJurH%2Fp9hwJ9TvlbLMxZObFZ8LenJhGuk77S%2FlJ2XIl3kpnDkLvmLCP%2BY3ivrmiQxnJigA4k4PiA0cosefbL%2BrzkxiPzUz%2FEh5owO84yCDpGwHlcyz6FggHY8DZY8CNcHnOPG9WWLwl54vxUfsrQ336hzyFQu5Qv1JvXi8MXWexWXziP%2BUemQY5HIUpw2IzrD%2Fl5FDR2KV5TwNhx8RhFJd1YOoi9eJwSyIh2486KVNxLyvkpWLsELNbYfWKo%2Fk7kmWGsTVHzK6I2FWEjRUvwRC2hzilUmMfb08QFdcBAohTW6fc%2BiEzRr1abFFLFKXCxxPnDXEhNltBC3FBYyu%2BXrIK6bqglLBgftc7LbSa1w1HnsLd7iEOqgaRL8%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIA3N6VV2Y2GFNDGC7T%2F20260722%2Fus-west-1%2Fs3%2Faws4_request&X-Amz-Date=20260722T202201Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=95c3bb1f62907709e9d559fb3c6dd6ab1467f85044c74671a9d071a7cf31e199'; const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; -const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_URL, oldPrice: 33999, price: 27199 }; + +// The real S3 URL above is ~1700 chars — WhatsApp interactive list rows cap `id` at +// 200 chars (#131009 "Row id is too long"), and buildValueEditId round-trips the +// full edits object through both the row id and (via GPT) the synthetic message +// text. So the *encoded* edits carry this short token instead of the real URL; it's +// expanded back to TV_PLACEHOLDER_IMAGE_URL in editGraphic before anything is sent +// to the Express API. +const TV_PLACEHOLDER_IMAGE_TOKEN = 'tv-model-image-placeholder'; +const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 33999, price: 27199 }; + +function expandPlaceholderEdits(edits) { + if (edits && edits.productImage === TV_PLACEHOLDER_IMAGE_TOKEN) { + return { ...edits, productImage: TV_PLACEHOLDER_IMAGE_URL }; + } + return edits; +} const MAX_DISCOUNT_PERCENT = 40; const ROUNDING_TOLERANCE_PERCENT = 0.5; @@ -115,6 +130,8 @@ async function checkAllowedEdits(image) { // phrase the final reply (matching the user's language) and deliver the image // with that phrasing as its caption in one message. async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { + edits = expandPlaceholderEdits(edits); + let elements; try { const doc = await expressApi.getTaggedDocument(image.docId); @@ -178,4 +195,5 @@ module.exports = { buildTopLevelEditOptions, MAX_DISCOUNT_PERCENT, TV_PLACEHOLDER_IMAGE_URL, + TV_PLACEHOLDER_IMAGE_TOKEN, }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index f3fab68d22..17c2bffb01 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -117,6 +117,24 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, and retu assert.deepEqual(updated.currentEdits, { cta: '20% off' }); }); +test('editGraphic expands the TV placeholder image token to the real S3 URL before calling generate-variation', async () => { + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.equal(tagMappings.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_URL); + assert.notEqual(tagMappings.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); + return { jobId: 'job-tv', statusUrl: 'https://express-api.adobe.io/status/job-tv' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/tv-thumb.png' } }); + const image = catalogImage('phone-tv'); + + const edits = { productImage: expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: '33999', price: '27199' }; + const result = await expressFlow.editGraphic('phone-tv', image, edits, {}); + + assert.equal(result.status, 'success'); + const updated = findTrackedImage('phone-tv', 'img_1'); + assert.equal(updated.currentEdits.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_URL); +}); + test('editGraphic returns an api_error/generate_failed status and does not record the edit when generation fails', async () => { expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; @@ -191,6 +209,14 @@ test('selectTvModel gives every option a unique id (WhatsApp list rows must not assert.equal(new Set(ids).size, ids.length); }); +test('selectTvModel keeps every option id within WhatsApp\'s 200-char row id limit (#131009)', () => { + const result = expressFlow.selectTvModel('img_1'); + + for (const option of result.options) { + assert.ok(option.id.length <= 200, `id too long (${option.id.length}): ${option.id}`); + } +}); + test('selectTvModel encodes the same fixed productImage/oldPrice/price edits into every option id', () => { const result = expressFlow.selectTvModel('img_1'); @@ -199,7 +225,7 @@ test('selectTvModel encodes the same fixed productImage/oldPrice/price edits int assert.deepEqual(parsed, { imageId: 'img_1', edits: { - productImage: expressFlow.TV_PLACEHOLDER_IMAGE_URL, + productImage: expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 33999, price: 27199, }, From 1c4fdce7854943d624a31a3f622ac6e5aec660c0 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 02:28:27 +0530 Subject: [PATCH 24/38] fix(express): use fixed Diwali-offer caption, delay edit menu, add button emojis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit_graphic success caption was a GPT-phrased one-liner; replace it with the actual Diwali banner promo copy (with the updated price baked in) so the message reads like real product marketing. Also pause briefly before sending the follow-up edit-menu buttons — WhatsApp delivers link-image messages a beat after text/interactive ones, so the buttons could arrive first even though the image is awaited first in code. Add emojis to the 3 fixed edit-menu buttons. Co-Authored-By: Claude Sonnet 5 --- src/actions.js | 7 +++++++ src/app.js | 24 ++++++++++++++++++++---- src/expressFlow.js | 41 +++++++++++++++++++++++++++++++++++++---- src/expressFlow.test.js | 6 +++--- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/actions.js b/src/actions.js index 82e179a6a6..02310f3a6e 100644 --- a/src/actions.js +++ b/src/actions.js @@ -37,6 +37,12 @@ function buildTopLevelEditOptions(imageId) { return expressFlow.buildTopLevelEditOptions(imageId); } +// Flow 1 (express/catalog) — the fixed Diwali-offer caption used as the image +// message text after a successful edit_graphic on img_1. +function buildDiwaliOfferCaption(result) { + return expressFlow.buildDiwaliOfferCaption(result); +} + // Router: resolve the image, then hand off to the flow that owns it. async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); @@ -73,4 +79,5 @@ module.exports = { actionGenerateBulkGraphics, actionSelectTvModel, buildTopLevelEditOptions, + buildDiwaliOfferCaption, }; diff --git a/src/app.js b/src/app.js index 4b97f515dd..3135473197 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ const { actionGenerateBulkGraphics, actionSelectTvModel, buildTopLevelEditOptions, + buildDiwaliOfferCaption, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); @@ -103,6 +104,17 @@ function sendList(to, { bodyText, buttonText, options }) { // go out as additional button messages rather than falling back to a list picker. const BUTTONS_PER_MESSAGE = 3; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// WhatsApp delivers link-image messages a beat after plain text/interactive +// messages (it has to fetch the image before it can display it), so awaiting +// sendImage() isn't enough to guarantee the image lands on-device before a +// message sent right after it. This pause gives the image a head start so the +// follow-up edit-menu buttons don't arrive first. +const IMAGE_DELIVERY_DELAY_MS = Number(process.env.IMAGE_DELIVERY_DELAY_MS ?? 1800); + async function sendEditOptions(to, result) { const { bodyText, options, buttonText } = result; if (options.length === 0) { @@ -431,23 +443,27 @@ app.post('/', async (req, res) => { // Progress is streamed from inside the flow. Local-flow outcomes are // either a plain string (guardrail rejection) or {skipSend:true, // historyText} (success, image+caption already sent). Express-flow - // outcomes are always a structured {status, ...} object — phrased here - // to match the user's language, delivered with that phrasing as the - // image caption, then followed by the fixed Edit Product/Discount/Price menu. + // outcomes are always a structured {status, ...} object. On success, + // the image is delivered with the fixed Diwali-offer caption (updated + // price baked in); other statuses are phrased to match the user's + // language. Either way, the fixed Edit Product/Discount/Price menu + // follows — after a short pause so it can't arrive before the image. const result = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage, sendText }); if (typeof result === 'string') { replyText = result; } else if (result.status) { - replyText = await phraseOutcome(phoneNumber, userText, result); if (result.status === 'success') { + replyText = buildDiwaliOfferCaption(result); try { await sendImage(phoneNumber, result.thumbnailUrl, replyText); + await sleep(IMAGE_DELIVERY_DELAY_MS); } catch (err) { console.error('[edit_graphic] sendImage error', { message: err.message }); replyText = `Updated "${result.productName}", but I couldn't send the image right now — try asking me to resend it.`; await sendText(phoneNumber, replyText); } } else { + replyText = await phraseOutcome(phoneNumber, userText, result); await sendText(phoneNumber, replyText); } await sendEditOptions(phoneNumber, buildTopLevelEditOptions(args.image_id)); diff --git a/src/expressFlow.js b/src/expressFlow.js index b6d1c5b086..e7fc519a9d 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -38,6 +38,35 @@ function expandPlaceholderEdits(edits) { return edits; } +function formatINR(amount) { + return Number(amount).toLocaleString('en-IN'); +} + +// The success caption for img_1 (Croma Diwali offer) — a fixed festive template +// rather than a GPT-phrased one-liner, so the banner's own promo copy carries +// through into the message text. Falls back to the TV_MODEL_EDITS constants for +// price/oldPrice when an edit (e.g. a lone "change price" request) hasn't gone +// through selectTvModel, so both are always populated. +function buildDiwaliOfferCaption({ price, oldPrice } = {}) { + const displayPrice = formatINR(price ?? TV_MODEL_EDITS.price); + const displayOldPrice = formatINR(oldPrice ?? TV_MODEL_EDITS.oldPrice); + + return `🪔✨ DIWALI DHAMAKA OFFER! ✨🪔 + +🎉 Upgrade your viewing experience this festive season with an amazing deal on the Sony Bravia K-75! + +💥 Special Festive Price: ₹${displayPrice} +Regular Price: ₹${displayOldPrice} + +✅ Trusted Sony Quality +✅ Limited Period Diwali Offer +✅ Great Savings for Your Family + +📞 Contact us today or visit our store before the offer ends! + +🎁 Hurry! Stocks are limited. Grab this festive deal now! 🛍️✨`; +} + const MAX_DISCOUNT_PERCENT = 40; const ROUNDING_TOLERANCE_PERCENT = 0.5; @@ -45,9 +74,9 @@ const ROUNDING_TOLERANCE_PERCENT = 0.5; // per-document field list; tapping one produces a bare "product"/"discount"/ // "price" field id via the existing interactive-reply scheme (interactiveReply.js). const TOP_LEVEL_EDIT_FIELDS = [ - { fieldName: 'product', title: 'Edit Product' }, - { fieldName: 'discount', title: 'Edit Discount' }, - { fieldName: 'price', title: 'Edit Price' }, + { fieldName: 'product', title: '🛍️ Edit Product' }, + { fieldName: 'discount', title: '🏷️ Edit Discount' }, + { fieldName: 'price', title: '💰 Edit Price' }, ]; function withCurrentEdits(elements, currentEdits) { @@ -185,7 +214,10 @@ async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { recordEdits(phoneNumber, image.id, edits); - return { status: 'success', productName: image.name, changes: edits, thumbnailUrl }; + const outcome = { status: 'success', productName: image.name, changes: edits, thumbnailUrl }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; } module.exports = { @@ -193,6 +225,7 @@ module.exports = { checkAllowedEdits, editGraphic, buildTopLevelEditOptions, + buildDiwaliOfferCaption, MAX_DISCOUNT_PERCENT, TV_PLACEHOLDER_IMAGE_URL, TV_PLACEHOLDER_IMAGE_TOKEN, diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 17c2bffb01..267f71d724 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -54,9 +54,9 @@ test('checkAllowedEdits returns the fixed Edit Product/Discount/Price menu, with assert.equal(reply.type, 'edit_options'); assert.equal(reply.bodyText, 'What would you like to change?'); assert.deepEqual(reply.options, [ - { id: 'edit:img_1:product', title: 'Edit Product' }, - { id: 'edit:img_1:discount', title: 'Edit Discount' }, - { id: 'edit:img_1:price', title: 'Edit Price' }, + { id: 'edit:img_1:product', title: '🛍️ Edit Product' }, + { id: 'edit:img_1:discount', title: '🏷️ Edit Discount' }, + { id: 'edit:img_1:price', title: '💰 Edit Price' }, ]); assert.match(reply.historyText, /Edit Product/); }); From f578e6384337fc87244eaa7eac98a686795aa730 Mon Sep 17 00:00:00 2001 From: Priyank Modi Date: Thu, 23 Jul 2026 03:06:16 +0530 Subject: [PATCH 25/38] fix(express): send numeric edit values as strings to generate-variation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adobe's Express API rejects a JSON number for a text tag with "Unsupported text value for tag: price" (422) — price/oldPrice are the first numeric edit fields this bot computes, so this only surfaced now. --- src/expressFlow.js | 10 +++++++++- src/expressFlow.test.js | 20 +++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index e7fc519a9d..3a43b448ff 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -201,9 +201,17 @@ async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + // Tagged text elements always hold string values (see getTaggedDocument), and + // Adobe's generate-variation API rejects a JSON number for a text tag with + // "Unsupported text value for tag: " (422) — so numeric edits like price/ + // oldPrice must be sent as strings even though they're computed as numbers. + const tagMappings = Object.fromEntries( + Object.entries(mergedEdits).map(([key, value]) => [key, String(value)]) + ); + let thumbnailUrl; try { - const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const { statusUrl } = await expressApi.generateVariation(image.docId, tagMappings, pages, preferredDocumentName); const result = await expressApi.pollJobStatus(statusUrl); thumbnailUrl = result.document.thumbnailUrl; console.log('[edit:express] resolved image', { imageId: image.id, docId: image.docId, thumbnailUrl }); diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 267f71d724..8bc9028c17 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -176,7 +176,7 @@ test('editGraphic applies a price edit at exactly the 40% cap (within rounding t writeFixtureCatalog([{ id: 'img_2', name: 'TV Product', docId: 'urn:doc:2' }]); expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; expressApi.generateVariation = async (docId, tagMappings) => { - assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 20399 }); + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: '33999', price: '20399' }); return { jobId: 'job-2', statusUrl: 'https://express-api.adobe.io/status/job-2' }; }; expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb2.png' } }); @@ -189,6 +189,24 @@ test('editGraphic applies a price edit at exactly the 40% cap (within rounding t assert.deepEqual(result.changes, { price: 20399 }); }); +test('editGraphic stringifies numeric edit values before calling generate-variation (Adobe rejects a JSON number for a text tag with "Unsupported text value")', async () => { + writeFixtureCatalog([{ id: 'img_3', name: 'TV Product', docId: 'urn:doc:3' }]); + expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings) => { + assert.deepEqual(tagMappings, { productImage: 'https://example.com/tv.png', oldPrice: '33999', price: '30000' }); + for (const value of Object.values(tagMappings)) assert.equal(typeof value, 'string'); + return { jobId: 'job-3', statusUrl: 'https://express-api.adobe.io/status/job-3' }; + }; + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb3.png' } }); + recordEdits('phone-11', 'img_3', { productImage: 'https://example.com/tv.png', oldPrice: 33999, price: 27199 }); + const image = findTrackedImage('phone-11', 'img_3'); + + const result = await expressFlow.editGraphic('phone-11', image, { price: 30000 }, {}); + + assert.equal(result.status, 'success'); + assert.equal(result.price, 30000); // outcome keeps the numeric value for caption formatting +}); + test('selectTvModel returns the 3 fixed TV model options with a list-picker body text and button', () => { const result = expressFlow.selectTvModel('img_1'); From 8e4dce92d67ccfc0985379a4122d9f5d6858d1ff Mon Sep 17 00:00:00 2001 From: varun kalra Date: Thu, 23 Jul 2026 17:54:02 +0530 Subject: [PATCH 26/38] feat(local): switch Flow 2 to a personalised automobile-insurance offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the local (canned) flow from the Onam festive design into the dealer→customer personalised offer story: a salesman creates an on-brand, personalised insurance offer for a specific customer, then localises it. - data/offer-design.json replaces onam-design.json: two canned creatives (English banner-apoorva, Hindi banner-apoorva-hindi), the HQ-approved plans, and the featured plan's price floor for governance - localFlow: createDesign now takes customer/model/plan/includeContact, streams personalised progress, and sends the English creative; editGraphic resolves English↔Hindi (robust: Devanagari OR the word "Hindi" under any key); language is the only editable slot (brand/price/plan locked) - app.js: create_design schema + system-prompt guidance rebuilt for the guided offer flow (plan picker → add contact? → anything else? → create), with the HQ price-floor governance injected from the design data so GPT refuses below-floor pricing; robust to phrasing variations - getOfferContext() surfaces plans + governance to the prompt - tests rewritten for the auto flow (create/caption/streaming, EN↔HI, governance context, router dispatch); 60 passing - demo-auto-flow.sh drives the full conversation (plan tap → contact → anything else → generate → governance refusal → translate to Hindi) Co-Authored-By: Claude Opus 4.8 (1M context) --- data/offer-design.json | 20 ++++ data/onam-design.json | 21 ---- demo-auto-flow.sh | 72 ++++++++++++++ src/actions.js | 6 ++ src/actions.test.js | 25 ++--- src/app.js | 41 ++++---- src/localFlow.js | 220 +++++++++++++++-------------------------- src/localFlow.test.js | 187 +++++++++++------------------------ 8 files changed, 274 insertions(+), 318 deletions(-) create mode 100644 data/offer-design.json delete mode 100644 data/onam-design.json create mode 100755 demo-auto-flow.sh diff --git a/data/offer-design.json b/data/offer-design.json new file mode 100644 index 0000000000..88bc3cfba1 --- /dev/null +++ b/data/offer-design.json @@ -0,0 +1,20 @@ +{ + "_comment": "Personalised customer offer used by create_design (source: 'local'). Two canned creatives — English and Hindi. Swap the image URLs for your hosted banners. Plans/featured drive the plan-picker buttons and the HQ price-floor governance in the system prompt.", + "images": { + "en": "http://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva", + "hi": "http://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi" + }, + "plans": ["3-Yr Comprehensive", "Zero Dep + RSA", "Engine Protect combo"], + "featured": { + "plan": "3-Yr Comprehensive", + "price": "₹28,999/year", + "was": "₹52,499/year", + "savings": "₹23,500/year" + }, + "slots": { + "editable": [ + { "name": "language", "type": "text", "aliases": ["lang", "translate", "translation", "headline", "text"] } + ], + "locked": ["logo", "brand", "price", "plan", "product"] + } +} diff --git a/data/onam-design.json b/data/onam-design.json deleted file mode 100644 index 0ff590537e..0000000000 --- a/data/onam-design.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "_comment": "Template used by the create_design tool for brand-new (source: 'local') creatives. Swap the placeholder image URLs below with your hosted PNG URLs. See README / actions.js for how they map to edit state.", - "images": { - "base": "http://s7ap1.scene7.com/is/image/varunAEM/onam-base?cache=off", - "final": "http://s7ap1.scene7.com/is/image/varunAEM/onam-final?cache=off", - "malayalam": "http://s7ap1.scene7.com/is/image/varunAEM/onam-malyalam?cache=off" - }, - "palette": [ - { "name": "Marigold", "hex": "#F4A300" }, - { "name": "Maroon", "hex": "#800020" }, - { "name": "Deep Green", "hex": "#1B5E20" } - ], - "slots": { - "editable": [ - { "name": "headline", "type": "text", "aliases": ["heading", "title", "header", "banner_text", "text"] }, - { "name": "background", "type": "color", "aliases": ["background_color", "bg", "colour", "color", "background_colour"] }, - { "name": "address", "type": "text", "aliases": ["store_address", "location", "store"] } - ], - "locked": ["logo", "product", "background_image"] - } -} diff --git a/demo-auto-flow.sh b/demo-auto-flow.sh new file mode 100755 index 0000000000..e5fb2a3c72 --- /dev/null +++ b/demo-auto-flow.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Drives the personalised automobile-insurance offer flow (Flow 2) against a +# running webhook, one step at a time. Watch the server logs for `GPT chose +# action:` and `[edit:local] resolved image`. +# +# Usage: bash demo-auto-flow.sh +# BASE_URL=http://localhost:3000 PHONE=919899860983 bash demo-auto-flow.sh + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:3000}" +PHONE="${PHONE:-919899860983}" +PAUSE="${PAUSE:-10}" +GEN_PAUSE="${GEN_PAUSE:-25}" + +# Send a free-text WhatsApp message. +send() { + echo ">>> [text] $1" + curl -s -X POST "$BASE_URL/" -H 'Content-Type: application/json' -d "$(cat </dev/null + echo " (200 ack — check server logs)" + echo +} + +# Tap a reply button (WhatsApp interactive button_reply). Arg = button title. +tap() { + echo ">>> [tap button] $1" + curl -s -X POST "$BASE_URL/" -H 'Content-Type: application/json' -d "$(cat </dev/null + echo " (200 ack — check server logs)" + echo +} + +echo "=== WhatsCraft personalised auto-insurance offer flow → $BASE_URL (phone: $PHONE) ===" +echo + +# 1) Salesman asks for a personalised offer → WC asks which HQ-approved plan [buttons] +send "Apoorva test drove the Grand Vitara yesterday and asked for insurance options. Create a personalised offer for her." +sleep "$PAUSE" + +# 2) Pick the plan → WC asks whether to add the salesman's contact [Yes/No] +tap "3-Yr Comprehensive" +sleep "$PAUSE" + +# 3) Yes to contact → WC asks "anything else?" [Yes / No, go ahead] +tap "Yes" +sleep "$PAUSE" + +# 4) Nothing else → WC streams progress for a few seconds, then sends the English banner. +# Wait for that generation to finish before the next step so messages don't interleave. +tap "No, go ahead" +echo "... waiting ${GEN_PAUSE}s for generation to stream + finish ..." +sleep "$GEN_PAUSE" + +# 5) Governance beat: ask to go below the HQ floor → WC refuses [Keep it / Pick another plan] +send "Can we go lower than 28,999?" +sleep "$PAUSE" + +# 6) Keep it → WC acknowledges +tap "Keep it" +sleep "$PAUSE" + +# 7) Translate → WC streams briefly, then sends the Hindi banner +send "make it in Hindi" + +echo "=== done — verify the two banners (English + Hindi) on WhatsApp and the tool choices in the logs ===" diff --git a/src/actions.js b/src/actions.js index 02310f3a6e..6dd62aca79 100644 --- a/src/actions.js +++ b/src/actions.js @@ -43,6 +43,11 @@ function buildDiwaliOfferCaption(result) { return expressFlow.buildDiwaliOfferCaption(result); } +// Flow 2 (local) — approved plans + governance for the GPT system prompt. +function getOfferContext() { + return localFlow.getOfferContext(); +} + // Router: resolve the image, then hand off to the flow that owns it. async function actionCheckAllowedEdits(phoneNumber, imageId) { const image = findTrackedImage(phoneNumber, imageId); @@ -80,4 +85,5 @@ module.exports = { actionSelectTvModel, buildTopLevelEditOptions, buildDiwaliOfferCaption, + getOfferContext, }; diff --git a/src/actions.test.js b/src/actions.test.js index a0d2d6b2b6..4b63c16b53 100644 --- a/src/actions.test.js +++ b/src/actions.test.js @@ -14,15 +14,16 @@ function writeFixtureCatalog(entries) { process.env.EXPRESS_TEMPLATES_FILE = fixturePath; } -function writeOnamFixture() { - const onam = { - images: { base: 'https://cdn.test/b.png', final: 'https://cdn.test/f.png', malayalam: 'https://cdn.test/m.png' }, - palette: [{ name: 'Marigold', hex: '#F4A300' }], - slots: { editable: [{ name: 'address', type: 'text', aliases: [] }], locked: [] }, +function writeOfferFixture() { + const offer = { + images: { en: 'https://cdn.test/en.png', hi: 'https://cdn.test/hi.png' }, + plans: ['3-Yr Comprehensive'], + featured: { plan: '3-Yr Comprehensive', price: '₹28,999/year', was: '₹52,499/year', savings: '₹23,500/year' }, + slots: { editable: [{ name: 'language', type: 'text', aliases: ['translate'] }], locked: [] }, }; - const p = path.join(os.tmpdir(), `onam-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); - fs.writeFileSync(p, JSON.stringify(onam)); - process.env.ONAM_DESIGN_FILE = p; + const p = path.join(os.tmpdir(), `offer-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(p, JSON.stringify(offer)); + process.env.OFFER_DESIGN_FILE = p; } test('actionCheckAllowedEdits reports unknown images without throwing', async () => { @@ -42,13 +43,13 @@ test('actionEditGraphic reports unknown images without throwing', async () => { }); test('routes a local-source image to the canned flow (no Express API call)', async () => { - writeOnamFixture(); + writeOfferFixture(); const sent = []; const sendImage = async (_to, link) => sent.push(link); - await actionCreateDesign('phone-r3', { occasion: 'Onam' }, { sendImage }); + await actionCreateDesign('phone-r3', { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive' }, { sendImage }); - const reply = await actionEditGraphic('phone-r3', 'local_1', { address: 'MG Road' }, { sendImage }); + const reply = await actionEditGraphic('phone-r3', 'local_1', { language: 'Hindi' }, { sendImage }); assert.match(reply.historyText, /anything else/i); - assert.equal(sent.at(-1), 'https://cdn.test/f.png'); // canned "final" URL — proves the local flow ran + assert.equal(sent.at(-1), 'https://cdn.test/hi.png'); // canned Hindi URL — proves the local flow ran }); diff --git a/src/app.js b/src/app.js index 3135473197..1dd8a3421f 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ const { actionSelectTvModel, buildTopLevelEditOptions, buildDiwaliOfferCaption, + getOfferContext, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); @@ -154,23 +155,19 @@ const tools = [ function: { name: 'create_design', description: - 'Create a brand-new single marketing creative from a text description, for an occasion or theme that has NO existing template (e.g. Onam, Pongal). Use when the user wants to make/create/design a new banner or creative from scratch. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', + 'Create a brand-new PERSONALISED offer creative for a specific customer (e.g. a personalised car-insurance offer for a customer who test drove a model). Call this only AFTER gathering the plan and contact details via ask_for_more_information. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', parameters: { type: 'object', properties: { - occasion: { type: 'string', description: 'The occasion or theme, e.g. "Onam"' }, - products: { - type: 'array', - items: { type: 'string' }, - description: 'Products to feature, e.g. ["LG washing machine", "dishwasher"]', - }, - offer: { type: 'string', description: 'The offer or discount to show, e.g. "20% off"' }, - includeAddress: { + customer: { type: 'string', description: "The customer's name, e.g. \"Apoorva\"" }, + model: { type: 'string', description: "The vehicle/product the customer is interested in, e.g. \"Grand Vitara\"" }, + plan: { type: 'string', description: "The chosen HQ-approved plan to feature, e.g. \"3-Yr Comprehensive\"" }, + includeContact: { type: 'boolean', - description: "Set true if the user has agreed to include their store address on the design; otherwise false.", + description: "Set true if the salesman wants their name & number added as the contact on the creative.", }, }, - required: ['occasion'], + required: [], }, }, }, @@ -274,6 +271,14 @@ async function decideAction(phoneNumber, userMessage) { .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) .join('\n'); + // Approved insurance plans + HQ price floor for the personalised-offer flow. + const { plans, featured } = getOfferContext(); + const plansLine = plans.join(', '); + const govLine = featured + ? `${featured.plan} — best approved rate ${featured.price} (was ${featured.was}, saving ${featured.savings})` + : ''; + const savings = featured?.savings || 'a lot'; + const messages = [ { role: 'system', @@ -282,12 +287,14 @@ Analyze the user's message and conversation history, then call the appropriate t Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. -Creating a brand-new design (create_design), gather details first with tappable buttons: -- If the user asks for a design but does NOT mention an occasion/festival, first call ask_for_more_information with options ["Yes","No"] to suggest Onam, e.g. "This is a great time for an Onam offer — want me to generate this for Onam?". -- Once an occasion is agreed (or was given up front), and before creating, call ask_for_more_information with options ["Yes","No"] to ask "Great! Do you also want to add your store address?". -- Then call create_design with the occasion, the products and offer mentioned earlier in the conversation, and includeAddress set from their address answer. -- Always attach options ["Yes","No"] to any yes/no question so the user can tap a button instead of typing. -- After the design is sent, if the user asks to change something (e.g. "change the language to Malayalam"), call edit_graphic. +Creating a personalised customer offer (create_design) — this is for a car-dealership salesman making an on-brand offer to send to a specific customer (e.g. "create a personalised insurance offer for Apoorva who test drove the Grand Vitara"). Gather details first with tappable buttons, BEFORE creating: +1. Call ask_for_more_information asking which HQ-approved plan to feature, with options: [${plansLine}]. +2. Then call ask_for_more_information with options ["Yes","No"] asking "Should I add your name & number so can reach you directly?". +3. Then call ask_for_more_information with options ["Yes","No, go ahead"] asking "Anything else you'd like to add before I create it?". +- Then call create_design with the customer's name, the model they were interested in, the chosen plan, and includeContact set from their contact answer. +Governance — the approved plan prices are the lowest allowed: ${govLine}. If the salesman asks to lower the price, give a bigger discount, or go below the approved rate, DO NOT do it — call ask_for_more_information to explain it's the best HQ-approved rate (already saving ${savings}) with options ["Keep it","Pick another plan"]. +- To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. +- Always attach options to any yes/no question so the salesman can tap a button instead of typing. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. diff --git a/src/localFlow.js b/src/localFlow.js index afedeebf3a..e0908698de 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -1,27 +1,34 @@ -// ── Flow 2: canned-image designs created at runtime (image.source === 'local') ─ -// No Adobe Express calls. A design is created from a text description and its -// edits resolve to pre-hosted image URLs defined in data/onam-design.json. -// -// Self-contained: this module must NOT depend on the express flow. It is reached -// only via the router in actions.js for images whose source is 'local'. +// ── Flow 2: canned personalised customer offers (image.source === 'local') ──── +// A car-dealership salesman creates a personalised, on-brand offer to send to a +// specific customer (e.g. an insurance offer for someone who test drove a model). +// No Adobe Express calls — the creative resolves to pre-hosted canned image URLs +// (English / Hindi) in data/offer-design.json. Self-contained: never touches the +// express flow. Governance (no below-floor pricing) is enforced in the GPT prompt. const fs = require('node:fs'); const path = require('node:path'); const { recordEdits, createDesign: registerDesign } = require('./imageStore'); const { buildEditOptions, formatAllowedEdits } = require('./editOptions'); -function loadOnamDesign() { - const filePath = process.env.ONAM_DESIGN_FILE || path.join(__dirname, '..', 'data', 'onam-design.json'); +function loadOfferDesign() { + const filePath = process.env.OFFER_DESIGN_FILE || path.join(__dirname, '..', 'data', 'offer-design.json'); return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +// Approved plans + featured governance details, injected into the GPT system +// prompt so it can offer the plan buttons and refuse below-floor price requests. +function getOfferContext() { + const design = loadOfferDesign(); + return { plans: design.plans || [], featured: design.featured || null }; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -// Send progress ("streaming") messages one at a time with a pause between them, -// so generation feels like real work rather than an instant response. No-op when -// no sender is provided (keeps unit tests fast). Delay is tunable for the demo. +// Stream progress messages one at a time with a pause between them, so generation +// feels like real work. No-op without a sender (keeps unit tests fast). Tunable +// via GEN_STEP_DELAY_MS. async function streamProgress(sendText, phoneNumber, messages) { if (typeof sendText !== 'function') return; const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1800); @@ -31,157 +38,79 @@ async function streamProgress(sendText, phoneNumber, messages) { } } -// create_design: register a brand-new local design and send its image with a -// friendly caption. If the user opted to include their address, the creative -// that carries it is the "final" image (we only ship 2 canned URLs for this flow). -async function createDesign(phoneNumber, { occasion, products, offer, includeAddress } = {}, { sendImage, sendText }) { - const design = loadOnamDesign(); - const productList = Array.isArray(products) && products.length ? products.join(' + ') : (products || 'your products'); - const festive = occasion || 'Festive'; - const name = [festive, productList, 'offer'].filter(Boolean).join(' '); - const image = registerDesign(phoneNumber, { name, design }); +function hasHindi(value) { + return /[ऀ-ॿ]/.test(String(value)); +} - const imageUrl = includeAddress ? design.images.final : design.images.base; - if (includeAddress) recordEdits(phoneNumber, image.id, { address: 'your store' }); +// A translation request: the model may pass Devanagari text or the word "Hindi" +// under any key. Detect either so "make it in Hindi" (however phrased) always maps +// to the Hindi creative. +function wantsHindi(rawEdits) { + const text = Object.entries(rawEdits || {}).flat().map(String); + return text.some(hasHindi) || text.some((s) => /\bhindi\b/i.test(s)); +} - console.log('[action:create_design]', { phoneNumber, occasion, products, offer, includeAddress, image: imageUrl }); +function localEditElements(image) { + return (image.design.slots?.editable || []).map((slot) => ({ + name: slot.name, + type: slot.type || 'text', + value: image.currentEdits[slot.name] ?? '', + })); +} + +// create_design: register a personalised offer and send the English creative, +// streaming progress first so it feels generated rather than instant. +async function createDesign(phoneNumber, { customer, model, plan, includeContact } = {}, { sendImage, sendText }) { + const design = loadOfferDesign(); + const who = customer || 'your customer'; + const car = model || 'their vehicle'; + const name = `${car} offer for ${who}`; + const image = registerDesign(phoneNumber, { name, design }); - // Stream the "work being done" so generation feels real, then send the image. const steps = [ - `🎨 Got it — creating your ${festive} creative now. Give me a few seconds…`, - `📦 Pulling Croma's logo and the approved ${festive} colour palette from the brand kit…`, - `📱 Adding your products: ${productList}…`, - `🏷️ Applying ${offer ? `your ${offer} offer` : 'your offer'} and festive ${festive} styling…`, + `🎨 Creating ${who}'s personalised offer…`, + '📦 Pulling Maruti Suzuki Arena branding & approved colours…', + `📱 Adding the ${car} they were interested in…`, + `🛡️ Applying the approved ${plan || 'insurance'} plan…`, ]; - if (includeAddress) steps.push('📍 Placing your store address…'); + if (includeContact) steps.push('📍 Personalising it and adding your contact…'); await streamProgress(sendText, phoneNumber, steps); - const offerText = offer ? ` at ${offer}` : ''; - const addressText = includeAddress ? ', with your store address' : ''; - // The follow-up prompt lives inside the caption so it never arrives before the - // image — WhatsApp delivers link images a beat after plain text. - const caption = `🌼 Happy ${festive}! Here's your festive creative — ${productList}${offerText}${addressText}. On-brand with Croma's logo and approved colours, ready to share. ✨\n\nWant to change anything? For example, I can translate the whole banner to Malayalam. 🌸`; + const contactText = includeContact ? ' with your contact' : ''; + const caption = `Here you go 🚗 ${who}'s personalised ${car} offer${contactText} — on-brand and ready to forward.\n\nWant to change anything?`; + console.log('[action:create_design]', { phoneNumber, customer, model, plan, includeContact, image: design.images.en }); try { - await sendImage(phoneNumber, imageUrl, caption); + await sendImage(phoneNumber, design.images.en, caption); } catch (err) { console.error('[localFlow.createDesign] sendImage error', { message: err.message }); - return `I built your ${festive} design, but couldn't send the image right now — try asking me to resend it.`; + return `I created ${who}'s offer, but couldn't send the image right now — try asking me to resend it.`; } return { skipSend: true, historyText: caption }; } -function normalizeKey(key) { - return String(key).toLowerCase().trim().replace(/\s+/g, '_'); -} - -// Map GPT's free-form edit keys onto the design's canonical slot names via -// aliases, so "background_color" / "colour" / "heading" all resolve correctly. -function canonicalizeEdits(editableSlots, edits) { - const canonical = {}; - const unknown = []; - for (const [rawKey, value] of Object.entries(edits || {})) { - const key = normalizeKey(rawKey); - const slot = editableSlots.find( - (s) => normalizeKey(s.name) === key || (s.aliases || []).some((a) => normalizeKey(a) === key) - ); - if (slot) canonical[slot.name] = value; - else unknown.push(rawKey); - } - return { canonical, unknown }; -} - -function isPaletteColor(design, value) { - const v = String(value).trim().toLowerCase(); - return (design.palette || []).some((c) => c.name.toLowerCase() === v || c.hex.toLowerCase() === v); -} - -function hasMalayalam(value) { - return /[ഀ-ൿ]/.test(String(value)); -} - -function isMalayalamEdit(currentEdits) { - return Object.entries(currentEdits).some(([key, value]) => - hasMalayalam(String(value)) || (key === 'language' && /malayalam/i.test(String(value))) - ); -} - -// Pick the canned image for the current accumulated edit state. -function resolveLocalImage(design, currentEdits) { - if (isMalayalamEdit(currentEdits)) return design.images.malayalam; - if (currentEdits.background || currentEdits.address) return design.images.final; - return design.images.base; -} - -function localEditElements(image) { - return image.design.slots.editable.map((slot) => ({ - name: slot.name, - type: slot.type || 'text', - value: image.currentEdits[slot.name] ?? '', - })); -} - -// What can be edited? — from the design's static slot schema (no API). -function checkAllowedEdits(image) { - const elements = localEditElements(image); - return { - type: 'edit_options', - bodyText: 'What would you like to change?', - options: buildEditOptions(elements, image.id), - historyText: formatAllowedEdits(image.name, elements), - }; -} - -// Apply edits by resolving to the matching canned image URL. +// Apply an edit. The only visual edit on this canned offer is the language +// (English ↔ Hindi); brand, price and plan are HQ-locked, so any other edit just +// re-sends the current creative. Below-floor pricing is refused upstream in the +// GPT prompt, so it never reaches here. async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText }) { const design = image.design; - const editableSlots = design.slots.editable; - - // Translation is special: the model may pass the Malayalam text (or the word - // "Malayalam") under any key. Detect it up front so a "translate to Malayalam" - // request always maps to the Malayalam creative, regardless of the edit key. - const rawText = Object.entries(rawEdits || {}).flat().map(String); - const wantsMalayalam = rawText.some(hasMalayalam) || rawText.some((s) => /malayalam/i.test(s)); - - // A translation request short-circuits everything: map straight to the - // Malayalam creative. Never treat it as a field edit or run it through the - // palette / locked-field guardrails (the model may put the Malayalam text on - // any key, including one that looks like "background"). - let appliedEdits; - if (wantsMalayalam) { - appliedEdits = { language: 'Malayalam' }; - } else { - const { canonical: edits, unknown } = canonicalizeEdits(editableSlots, rawEdits); - - if (unknown.length > 0) { - const editableNames = editableSlots.map((s) => s.name).join(', '); - return `I can't edit ${unknown.join(', ')} on "${image.name}" — those are locked by HQ. You can change: ${editableNames}.`; - } - - if ('background' in edits && !isPaletteColor(design, edits.background)) { - const options = design.palette.map((c) => c.name).join(' · '); - return `"${edits.background}" isn't in the approved palette 🙂 Here are the festive accents you can pick from: ${options}`; - } - - appliedEdits = edits; - } + const toHindi = wantsHindi(rawEdits); + const language = toHindi ? 'Hindi' : (image.currentEdits.language || 'English'); - recordEdits(phoneNumber, image.id, appliedEdits); - const currentEdits = { ...image.currentEdits, ...appliedEdits }; - const imageUrl = resolveLocalImage(design, currentEdits); - console.log('[edit:local] resolved image', { imageId: image.id, currentEdits, imageUrl }); + recordEdits(phoneNumber, image.id, { language }); + const imageUrl = language === 'Hindi' ? design.images.hi : design.images.en; + console.log('[edit:local] resolved image', { imageId: image.id, language, imageUrl }); - // Stream progress so the re-render feels real, then send the updated image. - const progress = wantsMalayalam - ? ['🌸 Translating your banner to Malayalam…', '✍️ Re-rendering with the Malayalam text…'] + const progress = toHindi + ? ['🌸 Translating your banner to Hindi…', '✍️ Re-rendering with the Hindi text…'] : ['✍️ Updating your creative…']; await streamProgress(sendText, phoneNumber, progress); - const summary = wantsMalayalam - ? '🌸 Here you go — your banner is now in Malayalam!' - : `✅ Done! Updated ${Object.entries(appliedEdits).map(([key, value]) => `${key} → ${value}`).join(', ')}.`; - // Follow-up lives in the caption so it can't arrive before the image. + const summary = toHindi + ? '🌸 Here you go — your banner is now in Hindi!' + : "✅ Done — here's your updated banner."; const caption = `${summary}\n\nAnything else you'd like to change?`; try { @@ -194,4 +123,15 @@ async function editGraphic(phoneNumber, image, rawEdits, { sendImage, sendText } return { skipSend: true, historyText: caption }; } -module.exports = { createDesign, checkAllowedEdits, editGraphic }; +// What can be edited? — from the design's static slot schema (no API). +function checkAllowedEdits(image) { + const elements = localEditElements(image); + return { + type: 'edit_options', + bodyText: 'What would you like to change?', + options: buildEditOptions(elements, image.id), + historyText: formatAllowedEdits(image.name, elements), + }; +} + +module.exports = { createDesign, editGraphic, checkAllowedEdits, getOfferContext }; diff --git a/src/localFlow.test.js b/src/localFlow.test.js index ffbf2be065..2880eaebbf 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -8,195 +8,126 @@ const { findTrackedImage } = require('./imageStore'); const FIXTURE = { images: { - base: 'https://cdn.test/onam-base.png', - final: 'https://cdn.test/onam-final.png', - malayalam: 'https://cdn.test/onam-malayalam.png', + en: 'https://cdn.test/banner-en.png', + hi: 'https://cdn.test/banner-hi.png', }, - palette: [ - { name: 'Marigold', hex: '#F4A300' }, - { name: 'Maroon', hex: '#800020' }, - { name: 'Deep Green', hex: '#1B5E20' }, - ], + plans: ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo'], + featured: { plan: '3-Yr Comprehensive', price: '₹28,999/year', was: '₹52,499/year', savings: '₹23,500/year' }, slots: { - editable: [ - { name: 'headline', type: 'text', aliases: ['heading', 'title'] }, - { name: 'background', type: 'color', aliases: ['background_color', 'colour', 'color'] }, - { name: 'address', type: 'text', aliases: ['store_address'] }, - ], - locked: ['logo', 'product'], + editable: [{ name: 'language', type: 'text', aliases: ['lang', 'translate', 'headline'] }], + locked: ['logo', 'price', 'plan'], }, }; function useFixture() { - const p = path.join(os.tmpdir(), `onam-design-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const p = path.join(os.tmpdir(), `offer-design-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); fs.writeFileSync(p, JSON.stringify(FIXTURE)); - process.env.ONAM_DESIGN_FILE = p; + process.env.OFFER_DESIGN_FILE = p; } -// Captures both the image link and its caption so we can assert on the -// descriptive text sent alongside each image. +// Captures both the image link and its caption. function captureSendImage() { const sent = []; return { sendImage: async (_to, link, caption) => sent.push({ link, caption }), sent }; } -// Create a design for a phone (image sent) and return its tracked image object. -async function setup(phone, args = { occasion: 'Onam' }) { +// Create an offer for a phone (English image sent) and return its tracked image. +async function setup(phone, args = { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive' }) { useFixture(); const { sendImage, sent } = captureSendImage(); await localFlow.createDesign(phone, args, { sendImage }); return { image: findTrackedImage(phone, 'local_1'), sendImage, sent }; } -test('createDesign registers a local design and sends the base image with a caption', async () => { +test('createDesign sends the English creative with a personalised caption', async () => { useFixture(); const { sendImage, sent } = captureSendImage(); const reply = await localFlow.createDesign( - 'onam-phone-1', - { occasion: 'Onam', products: ['LG washing machine', 'dishwasher'], offer: '20% off' }, + 'auto-1', + { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive', includeContact: true }, { sendImage } ); - assert.equal(sent[0].link, FIXTURE.images.base); - assert.match(sent[0].caption, /Onam/); - assert.match(sent[0].caption, /20% off/); - assert.match(sent[0].caption, /change anything/i); // follow-up is in the caption, not a separate text + assert.equal(sent[0].link, FIXTURE.images.en); + assert.match(sent[0].caption, /Apoorva/); + assert.match(sent[0].caption, /Grand Vitara/); + assert.match(sent[0].caption, /change anything/i); assert.equal(reply.skipSend, true); }); -test('createDesign with includeAddress sends the with-address (final) image', async () => { - useFixture(); - const { sendImage, sent } = captureSendImage(); - - await localFlow.createDesign( - 'onam-phone-addr', - { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, - { sendImage } - ); - - assert.equal(sent[0].link, FIXTURE.images.final); - assert.match(sent[0].caption, /store address/); -}); - -test('createDesign streams progress messages (products + address) before the image', async () => { +test('createDesign streams progress (naming the model + contact) before the image', async () => { useFixture(); process.env.GEN_STEP_DELAY_MS = '0'; // no real pause in tests const texts = []; - const sendText = async (_to, msg) => texts.push(msg); const { sendImage, sent } = captureSendImage(); await localFlow.createDesign( - 'onam-stream-1', - { occasion: 'Onam', products: ['Samsung Galaxy S26', 'Galaxy Buds 3'], offer: '20% off', includeAddress: true }, - { sendImage, sendText } + 'auto-2', + { customer: 'Apoorva', model: 'Grand Vitara', plan: '3-Yr Comprehensive', includeContact: true }, + { sendImage, sendText: async (_t, m) => texts.push(m) } ); assert.ok(texts.length >= 4, 'streams several progress messages'); - assert.ok(texts.some((m) => /Samsung Galaxy S26 \+ Galaxy Buds 3/.test(m)), 'names the products'); - assert.ok(texts.some((m) => /store address/i.test(m)), 'mentions the address step'); - assert.equal(sent[0].link, FIXTURE.images.final); // image sent after the stream -}); - -test('editGraphic streams Malayalam progress before sending the Malayalam image', async () => { - process.env.GEN_STEP_DELAY_MS = '0'; - const { image } = await setup('onam-stream-2'); - const texts = []; - const sent = []; - const sendText = async (_to, msg) => texts.push(msg); - - await localFlow.editGraphic('onam-stream-2', image, { language: 'Malayalam' }, { - sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }), - sendText, - }); - - assert.ok(texts.some((m) => /Malayalam/i.test(m)), 'streams a Malayalam progress message'); - assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); -}); - -test('checkAllowedEdits lists the editable slots from the static schema', async () => { - const { image } = await setup('onam-phone-check'); - - const result = localFlow.checkAllowedEdits(image); - - assert.equal(result.type, 'edit_options'); - assert.deepEqual(result.options.map((o) => o.title), ['Change headline', 'Change background', 'Change address']); - assert.match(result.historyText, /headline/); -}); - -test('editing to an off-palette background is rejected with the approved options', async () => { - const { image, sent } = await setup('onam-phone-2'); - - const reply = await localFlow.editGraphic('onam-phone-2', image, { background: 'Pink' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - - assert.match(reply, /isn't in the approved palette/); - assert.match(reply, /Marigold · Maroon · Deep Green/); - assert.equal(sent.length, 1); // only the create image; no new image on a rejected edit -}); - -test('an approved-palette background + address resolves to the final image', async () => { - const { image, sent } = await setup('onam-phone-3'); - - await localFlow.editGraphic('onam-phone-3', image, { background: 'Marigold', address: 'MG Road, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - - assert.equal(sent.at(-1).link, FIXTURE.images.final); -}); - -test('a Malayalam headline resolves to the Malayalam image with a Malayalam caption', async () => { - const { image, sent } = await setup('onam-phone-4'); - - await localFlow.editGraphic('onam-phone-4', image, { headline: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - - assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); - assert.match(sent.at(-1).caption, /Malayalam/); + assert.ok(texts.some((m) => /Grand Vitara/.test(m)), 'names the model'); + assert.ok(texts.some((m) => /contact/i.test(m)), 'mentions the contact step'); + assert.equal(sent[0].link, FIXTURE.images.en); }); -test('adding only a store address resolves to the final image (demo msg 2)', async () => { - const { image, sent } = await setup('onam-phone-a'); +test('editGraphic translates to the Hindi creative when Devanagari text is passed', async () => { + const { image, sent } = await setup('auto-3'); - await localFlow.editGraphic('onam-phone-a', image, { address: 'Princess Street, Kochi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('auto-3', image, { headline: 'नमस्ते अपूर्वा' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - assert.equal(sent.at(-1).link, FIXTURE.images.final); + assert.equal(sent.at(-1).link, FIXTURE.images.hi); + assert.match(sent.at(-1).caption, /Hindi/); }); -test('a translate request under an unrecognized key still resolves to Malayalam (demo msg 3)', async () => { - const { image, sent } = await setup('onam-phone-b'); +test('editGraphic translates to Hindi when the word "Hindi" is used under any key', async () => { + const { image, sent } = await setup('auto-4'); - await localFlow.editGraphic('onam-phone-b', image, { language: 'Malayalam' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('auto-4', image, { language: 'Hindi' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - // resolving to the Malayalam image (rather than a "locked by HQ" text) proves it wasn't rejected - assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.hi); }); -test('a Malayalam value under any key resolves to Malayalam', async () => { - const { image, sent } = await setup('onam-phone-c'); +test('editGraphic streams Hindi progress before sending the Hindi image', async () => { + process.env.GEN_STEP_DELAY_MS = '0'; + const { image } = await setup('auto-5'); + const texts = []; + const sent = []; - await localFlow.editGraphic('onam-phone-c', image, { banner: 'ഓണം ആശംസകൾ' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('auto-5', image, { language: 'Hindi' }, { + sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }), + sendText: async (_t, m) => texts.push(m), + }); - assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); + assert.ok(texts.some((m) => /Hindi/i.test(m))); + assert.equal(sent.at(-1).link, FIXTURE.images.hi); }); -test('a Malayalam value landing on the background key translates, not palette-rejected (regression)', async () => { - const { image, sent } = await setup('onam-phone-d'); +test('editGraphic keeps the English creative for a non-language edit', async () => { + const { image, sent } = await setup('auto-6'); - await localFlow.editGraphic('onam-phone-d', image, { background: 'ഓണം' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + await localFlow.editGraphic('auto-6', image, { note: 'add urgency' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); - // resolving to the Malayalam image proves it wasn't palette-rejected - assert.equal(sent.at(-1).link, FIXTURE.images.malayalam); + assert.equal(sent.at(-1).link, FIXTURE.images.en); }); -test('editing a locked element is refused', async () => { - const { image } = await setup('onam-phone-5'); +test('checkAllowedEdits lists the editable slots from the schema', async () => { + const { image } = await setup('auto-7'); - const reply = await localFlow.editGraphic('onam-phone-5', image, { logo: 'brighter' }, { sendImage: async () => {} }); + const result = localFlow.checkAllowedEdits(image); - assert.match(reply, /locked by HQ/); + assert.equal(result.type, 'edit_options'); + assert.deepEqual(result.options.map((o) => o.title), ['Change language']); }); -test('edit-key aliases map onto canonical slot names (colour -> background)', async () => { - const { image, sent } = await setup('onam-phone-6'); +test('getOfferContext exposes the approved plans and featured governance', () => { + useFixture(); - await localFlow.editGraphic('onam-phone-6', image, { colour: 'Deep Green' }, { sendImage: async (_t, l, c) => sent.push({ link: l, caption: c }) }); + const ctx = localFlow.getOfferContext(); - assert.equal(sent.at(-1).link, FIXTURE.images.final); + assert.deepEqual(ctx.plans, FIXTURE.plans); + assert.equal(ctx.featured.price, '₹28,999/year'); }); From 647b17bc0a538774407365ec03f9fd9b6b3ef201 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Thu, 23 Jul 2026 18:30:16 +0530 Subject: [PATCH 27/38] fix(local): don't repeat the governance message after the salesman accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With tool_choice:'required', tapping "Keep it" made GPT re-fire the same price-floor explanation. Add explicit guidance: an acceptance ("Keep it") is a brief acknowledgement via ask_for_more_information (no options) — never a repeat of the governance message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index 1dd8a3421f..83c7854315 100644 --- a/src/app.js +++ b/src/app.js @@ -278,6 +278,9 @@ async function decideAction(phoneNumber, userMessage) { ? `${featured.plan} — best approved rate ${featured.price} (was ${featured.was}, saving ${featured.savings})` : ''; const savings = featured?.savings || 'a lot'; + const keepItAck = featured + ? `Great — keeping the ${featured.plan} at ${featured.price}. Anything else you'd like to change?` + : "Great — keeping it as is. Anything else you'd like to change?"; const messages = [ { @@ -292,7 +295,7 @@ Creating a personalised customer offer (create_design) — this is for a car-dea 2. Then call ask_for_more_information with options ["Yes","No"] asking "Should I add your name & number so can reach you directly?". 3. Then call ask_for_more_information with options ["Yes","No, go ahead"] asking "Anything else you'd like to add before I create it?". - Then call create_design with the customer's name, the model they were interested in, the chosen plan, and includeContact set from their contact answer. -Governance — the approved plan prices are the lowest allowed: ${govLine}. If the salesman asks to lower the price, give a bigger discount, or go below the approved rate, DO NOT do it — call ask_for_more_information to explain it's the best HQ-approved rate (already saving ${savings}) with options ["Keep it","Pick another plan"]. +Governance — the approved plan prices are the lowest allowed: ${govLine}. If the salesman asks to lower the price, give a bigger discount, or go below the approved rate, DO NOT do it — call ask_for_more_information to explain it's the best HQ-approved rate (already saving ${savings}) with options ["Keep it","Pick another plan"]. When the salesman then taps "Keep it" or otherwise accepts the price, do NOT repeat that explanation — acknowledge briefly by calling ask_for_more_information with NO options, e.g. "${keepItAck}". - To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. - Always attach options to any yes/no question so the salesman can tap a button instead of typing. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. From 83e32368094bbf9a84d7654078d2c3a02b3592ce Mon Sep 17 00:00:00 2001 From: varun kalra Date: Thu, 23 Jul 2026 21:08:29 +0530 Subject: [PATCH 28/38] feat(local): drop the price-governance step; merge two streaming lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove the insurance-amount governance beat (plan/price refusal + "Keep it") from the create flow — not needed - merge "Adding the …" and "Applying the approved plan…" into one streaming line to cut a message - demo-auto-flow.sh: remove the governance steps (generate → translate to Hindi) Co-Authored-By: Claude Opus 4.8 (1M context) --- demo-auto-flow.sh | 10 +--------- src/app.js | 12 ++---------- src/localFlow.js | 3 +-- 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/demo-auto-flow.sh b/demo-auto-flow.sh index e5fb2a3c72..f14bf79414 100755 --- a/demo-auto-flow.sh +++ b/demo-auto-flow.sh @@ -58,15 +58,7 @@ tap "No, go ahead" echo "... waiting ${GEN_PAUSE}s for generation to stream + finish ..." sleep "$GEN_PAUSE" -# 5) Governance beat: ask to go below the HQ floor → WC refuses [Keep it / Pick another plan] -send "Can we go lower than 28,999?" -sleep "$PAUSE" - -# 6) Keep it → WC acknowledges -tap "Keep it" -sleep "$PAUSE" - -# 7) Translate → WC streams briefly, then sends the Hindi banner +# 5) Translate → WC streams briefly, then sends the Hindi banner send "make it in Hindi" echo "=== done — verify the two banners (English + Hindi) on WhatsApp and the tool choices in the logs ===" diff --git a/src/app.js b/src/app.js index 83c7854315..d54269bc23 100644 --- a/src/app.js +++ b/src/app.js @@ -271,16 +271,9 @@ async function decideAction(phoneNumber, userMessage) { .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) .join('\n'); - // Approved insurance plans + HQ price floor for the personalised-offer flow. - const { plans, featured } = getOfferContext(); + // Approved plans offered by the personalised-offer flow (for the plan picker). + const { plans } = getOfferContext(); const plansLine = plans.join(', '); - const govLine = featured - ? `${featured.plan} — best approved rate ${featured.price} (was ${featured.was}, saving ${featured.savings})` - : ''; - const savings = featured?.savings || 'a lot'; - const keepItAck = featured - ? `Great — keeping the ${featured.plan} at ${featured.price}. Anything else you'd like to change?` - : "Great — keeping it as is. Anything else you'd like to change?"; const messages = [ { @@ -295,7 +288,6 @@ Creating a personalised customer offer (create_design) — this is for a car-dea 2. Then call ask_for_more_information with options ["Yes","No"] asking "Should I add your name & number so can reach you directly?". 3. Then call ask_for_more_information with options ["Yes","No, go ahead"] asking "Anything else you'd like to add before I create it?". - Then call create_design with the customer's name, the model they were interested in, the chosen plan, and includeContact set from their contact answer. -Governance — the approved plan prices are the lowest allowed: ${govLine}. If the salesman asks to lower the price, give a bigger discount, or go below the approved rate, DO NOT do it — call ask_for_more_information to explain it's the best HQ-approved rate (already saving ${savings}) with options ["Keep it","Pick another plan"]. When the salesman then taps "Keep it" or otherwise accepts the price, do NOT repeat that explanation — acknowledge briefly by calling ask_for_more_information with NO options, e.g. "${keepItAck}". - To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. - Always attach options to any yes/no question so the salesman can tap a button instead of typing. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. diff --git a/src/localFlow.js b/src/localFlow.js index e0908698de..0c56f30d22 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -70,8 +70,7 @@ async function createDesign(phoneNumber, { customer, model, plan, includeContact const steps = [ `🎨 Creating ${who}'s personalised offer…`, '📦 Pulling Maruti Suzuki Arena branding & approved colours…', - `📱 Adding the ${car} they were interested in…`, - `🛡️ Applying the approved ${plan || 'insurance'} plan…`, + `📱 Adding the ${car} with the approved ${plan || 'insurance'} plan…`, ]; if (includeContact) steps.push('📍 Personalising it and adding your contact…'); await streamProgress(sendText, phoneNumber, steps); From 0df6bc68dce94bc13f4eeb4670801280044e695e Mon Sep 17 00:00:00 2001 From: varun kalra Date: Thu, 23 Jul 2026 21:11:04 +0530 Subject: [PATCH 29/38] fix(local): use https image URLs without cache-busting for reliable delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the offer banner URLs to https:// and drop ?cache=off. WhatsApp needs https for link media, and cache-busting forced a slower origin fetch — together the likely cause of intermittent missing images. Co-Authored-By: Claude Opus 4.8 (1M context) --- data/offer-design.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/offer-design.json b/data/offer-design.json index 88bc3cfba1..03f93e5547 100644 --- a/data/offer-design.json +++ b/data/offer-design.json @@ -1,8 +1,8 @@ { "_comment": "Personalised customer offer used by create_design (source: 'local'). Two canned creatives — English and Hindi. Swap the image URLs for your hosted banners. Plans/featured drive the plan-picker buttons and the HQ price-floor governance in the system prompt.", "images": { - "en": "http://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva", - "hi": "http://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi" + "en": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva", + "hi": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi" }, "plans": ["3-Yr Comprehensive", "Zero Dep + RSA", "Engine Protect combo"], "featured": { From d13afe9857b762350bc619e19e4a6671b6bfe07f Mon Sep 17 00:00:00 2001 From: varun kalra Date: Thu, 23 Jul 2026 21:12:02 +0530 Subject: [PATCH 30/38] fix(local): keep ?cache=off on the https offer image URLs Restore the cache-busting query param (still on https) per preference. Co-Authored-By: Claude Opus 4.8 (1M context) --- data/offer-design.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/offer-design.json b/data/offer-design.json index 03f93e5547..f5533d158a 100644 --- a/data/offer-design.json +++ b/data/offer-design.json @@ -1,8 +1,8 @@ { "_comment": "Personalised customer offer used by create_design (source: 'local'). Two canned creatives — English and Hindi. Swap the image URLs for your hosted banners. Plans/featured drive the plan-picker buttons and the HQ price-floor governance in the system prompt.", "images": { - "en": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva", - "hi": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi" + "en": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva?cache=off", + "hi": "https://s7ap1.scene7.com/is/image/varunAEM/banner-apoorva-hindi?cache=off" }, "plans": ["3-Yr Comprehensive", "Zero Dep + RSA", "Engine Protect combo"], "featured": { From 38af5b3d4e80e70a02788707e95d024180cf33cc Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:52:46 +0530 Subject: [PATCH 31/38] Fix/tv image s3 presign and discount percentage (#15) * fix(express): update discountPercentage tag alongside price on discount edits Discount edits were only computing and setting price, leaving the document's discountPercentage tag stale even though the price had changed. Co-Authored-By: Claude Sonnet 5 * fix(express): auto-generate the TV product image S3 URL instead of a hardcoded one The previous presigned S3 URL for the TV placeholder image was hand-generated and expired after ~12h, requiring manual regeneration. Now the Scene7 source image is downloaded and re-uploaded to S3 on demand (ported from dynamicmedia-autoreflow's ImageS3Uploader), so the presigned URL is always fresh. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- package.json | 2 + render.yaml | 8 ++ src/app.js | 2 +- src/express/s3Upload.js | 56 ++++++++ src/expressFlow.js | 36 ++--- src/expressFlow.test.js | 12 +- yarn.lock | 292 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 src/express/s3Upload.js diff --git a/package.json b/package.json index f68ca9be99..c4614b7e7d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "test": "node --test" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1094.0", + "@aws-sdk/s3-request-presigner": "^3.1094.0", "express": "^5.0.0", "openai": "^6.45.0" } diff --git a/render.yaml b/render.yaml index 869c1badac..11984f61d0 100644 --- a/render.yaml +++ b/render.yaml @@ -36,3 +36,11 @@ services: sync: false - key: EXPRESS_STATUS_POLL_TIMEOUT_MS sync: false + - key: AWS_ACCESS_KEY_ID + sync: false + - key: AWS_SECRET_ACCESS_KEY + sync: false + - key: AWS_REGION + sync: false + - key: S3_BUCKET_NAME + sync: false diff --git a/src/app.js b/src/app.js index d54269bc23..d2f8c6c4b4 100644 --- a/src/app.js +++ b/src/app.js @@ -296,7 +296,7 @@ If the user asks to translate a tag's text into another language (e.g. "change t When the user taps a menu option for "product", "discount", or "price" (from the fixed Edit Product/Edit Discount/Edit Price menu on an Express-catalog graphic): - "product": call select_tv_model. - "discount" or "price" with no value given yet: call ask_for_more_information asking what they'd like the new discount or price to be. -- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with only { "price": } — never change oldPrice. +- "discount" WITH a value (a percentage, in English, Hindi, or Hinglish — e.g. "50%", "discount ko 50% kar do", "40% off"): compute the new price yourself as oldPrice × (1 − discountPercent / 100), rounded to the nearest whole number, using the oldPrice shown in the images list below, then call edit_graphic with { "price": , "discountPercentage": "%" } — never change oldPrice. - "price" WITH a value: call edit_graphic with { "price": } directly, no computation needed. Images previously sent to this user (reference by id): diff --git a/src/express/s3Upload.js b/src/express/s3Upload.js new file mode 100644 index 0000000000..2e297ac2c6 --- /dev/null +++ b/src/express/s3Upload.js @@ -0,0 +1,56 @@ +// Downloads a source image and re-hosts it on S3, returning a short-lived +// presigned GET URL. Adobe's generate-variation API only accepts S3, Dropbox, +// or Azure URLs for image tagMappings (see VariationDetails.tagMappings in the +// Express API spec) — CDNs like Scene7 are rejected — so any source image +// coming from elsewhere must be proxied through S3 first. +// +// Ported from the upload_from_url flow in dynamicmedia-autoreflow's +// ImageS3Uploader (Python, git.corp.adobe.com/CQ/dynamicmedia-autoreflow) to +// the AWS SDK for JS v3. +const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + +const DEFAULT_EXPIRES_IN_SECONDS = 1800; // 30 min, matches the Python default +const CACHE_SAFETY_MARGIN_SECONDS = 60; + +const s3Client = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' }); + +// imageUrl+s3Key -> { url, expiresAt } — avoids re-downloading/re-uploading +// and re-signing on every call for a URL that's still valid. +const presignedUrlCache = new Map(); + +function s3KeyFromUrl(imageUrl) { + const { pathname } = new URL(imageUrl); + return pathname.split('/').filter(Boolean).pop() || 'image.jpg'; +} + +async function uploadFromUrl(imageUrl, { s3Key, expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS } = {}) { + const bucket = process.env.S3_BUCKET_NAME; + const key = s3Key || s3KeyFromUrl(imageUrl); + const cacheKey = `${imageUrl}:${key}`; + + const cached = presignedUrlCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.url; + } + + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to download image ${imageUrl}: ${response.status}`); + } + const body = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + await s3Client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType })); + + const presignedUrl = await getSignedUrl(s3Client, new GetObjectCommand({ Bucket: bucket, Key: key }), { + expiresIn: expiresInSeconds, + }); + + const cacheTtlMs = Math.max(expiresInSeconds - CACHE_SAFETY_MARGIN_SECONDS, CACHE_SAFETY_MARGIN_SECONDS) * 1000; + presignedUrlCache.set(cacheKey, { url: presignedUrl, expiresAt: Date.now() + cacheTtlMs }); + + return presignedUrl; +} + +module.exports = { uploadFromUrl }; diff --git a/src/expressFlow.js b/src/expressFlow.js index 3a43b448ff..2162750832 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -8,32 +8,34 @@ const { recordEdits } = require('./imageStore'); const expressApi = require('./express/expressApi'); +const s3Upload = require('./express/s3Upload'); const { buildValueEditId } = require('./interactiveReply'); const { formatAllowedEdits } = require('./editOptions'); // A "change the product to a TV" request offers 3 fixed models as quick replies. // -// Must be a pre-signed URL on a domain Adobe's generate-variation API accepts for -// image tagMappings — AWS S3, Dropbox, or Azure (windows.net) only (see -// VariationDetails.tagMappings in the Express API spec). A Scene7 CDN URL was used -// here previously and Adobe rejected it, since scene7.com isn't an allowed domain. -// This S3 URL is itself pre-signed and expires (~12h from generation on 2026-07-22) -// — it will need to be regenerated/replaced before then to keep working. -const TV_PLACEHOLDER_IMAGE_URL = 'https://pmodi2.s3.us-west-1.amazonaws.com/SonyTv.png?response-content-disposition=inline&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBUaCXVzLXdlc3QtMSJHMEUCIAR%2BhaDDo11C4l%2BTaARgAfjNmrzEI4Odss6xvwmkN7pSAiEA8WvY0XqBgrvf97l8oq2vXo9wzPcVOTB%2FmhhOljmHp%2FgqhQQI3v%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw3ODU4OTAyNjg3MjQiDAa0WPLo6XDtvyGwYSrZA%2F91LMmqkGuC97Gp1YGw35bNLB1ci0qtqw8DOy%2BNsRyehXLhxaN3H5uifrQunTBrfC9jYEt5IGonDgnatWKi3rSOO2%2BPioo7FamZyIbroeniI%2BMy8mdV9wYCHQweXb3w6YD2eGxAXvDUxGLMnDL60ZAZ4DrcL5o%2BmtMOMvQi6brBdODM5k8YxRDMhnBb1gT4h%2FVuBO67na7LdNwDnx%2BY7Q4Dl4xbYHbrieEl9FRXHk%2Fd4v4rVWCVynJPgmL7m%2B4qwmKJjfX4aeHFt8criBiJzqcaTJdL1UzZsIeqf3icwvHIabvlmCigoHBBLykfuRm6HLkY1onUoh1z0YC5otVgQmss0nz73L4jHwaKIQSLgQDm%2B%2BUwfljiYfz1A8Pfbf5OObziWY%2F4L11qqJzrE0QPanFPaUGdZHbsxBz88JhHtUos61sZ4CPWMrpgLYElxupxegfzE45MXTWqWzIHoqPQlok%2B5137knQLH1VzLoTlS%2BeWg5jADKIWARAbOz2SeaXQ9GoIyZvFVK0%2FJSk1FeWdLRnrabUY%2Bp%2Ba0df6n%2BaS1fQdW2baqPbE%2FzSXOYplregbzUNrPimfWVyb%2BFLt3q5D2qFpAql4BVyZQdH6cT6PNMytziUIkkCZSO6yMOLLhNMGOrcCOM8ML%2FbFF6E9GCVwCIDLfS89AoYr56a9l%2B9FaySJurH%2Fp9hwJ9TvlbLMxZObFZ8LenJhGuk77S%2FlJ2XIl3kpnDkLvmLCP%2BY3ivrmiQxnJigA4k4PiA0cosefbL%2BrzkxiPzUz%2FEh5owO84yCDpGwHlcyz6FggHY8DZY8CNcHnOPG9WWLwl54vxUfsrQ336hzyFQu5Qv1JvXi8MXWexWXziP%2BUemQY5HIUpw2IzrD%2Fl5FDR2KV5TwNhx8RhFJd1YOoi9eJwSyIh2486KVNxLyvkpWLsELNbYfWKo%2Fk7kmWGsTVHzK6I2FWEjRUvwRC2hzilUmMfb08QFdcBAohTW6fc%2BiEzRr1abFFLFKXCxxPnDXEhNltBC3FBYyu%2BXrIK6bqglLBgftc7LbSa1w1HnsLd7iEOqgaRL8%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIA3N6VV2Y2GFNDGC7T%2F20260722%2Fus-west-1%2Fs3%2Faws4_request&X-Amz-Date=20260722T202201Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=95c3bb1f62907709e9d559fb3c6dd6ab1467f85044c74671a9d071a7cf31e199'; +// The source product photo lives on Adobe's Scene7 CDN, but Adobe's own +// generate-variation API rejects scene7.com for image tagMappings — only AWS S3, +// Dropbox, or Azure (windows.net) URLs are accepted (see VariationDetails.tagMappings +// in the Express API spec). So it's re-hosted on S3 (upload + presigned GET URL, +// via s3Upload.uploadFromUrl) at edit time instead of a one-off hand-uploaded URL +// that itself expires and has to be manually regenerated. +const TV_PRODUCT_SOURCE_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; -// The real S3 URL above is ~1700 chars — WhatsApp interactive list rows cap `id` at -// 200 chars (#131009 "Row id is too long"), and buildValueEditId round-trips the -// full edits object through both the row id and (via GPT) the synthetic message -// text. So the *encoded* edits carry this short token instead of the real URL; it's -// expanded back to TV_PLACEHOLDER_IMAGE_URL in editGraphic before anything is sent -// to the Express API. +// The presigned S3 URL is long — WhatsApp interactive list rows cap `id` at 200 +// chars (#131009 "Row id is too long"), and buildValueEditId round-trips the full +// edits object through both the row id and (via GPT) the synthetic message text. +// So the *encoded* edits carry this short token instead of the real URL; it's +// expanded to a fresh presigned S3 URL in editGraphic before anything is sent to +// the Express API. const TV_PLACEHOLDER_IMAGE_TOKEN = 'tv-model-image-placeholder'; const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 33999, price: 27199 }; -function expandPlaceholderEdits(edits) { +async function expandPlaceholderEdits(edits) { if (edits && edits.productImage === TV_PLACEHOLDER_IMAGE_TOKEN) { - return { ...edits, productImage: TV_PLACEHOLDER_IMAGE_URL }; + const signedUrl = await s3Upload.uploadFromUrl(TV_PRODUCT_SOURCE_IMAGE_URL); + return { ...edits, productImage: signedUrl }; } return edits; } @@ -159,7 +161,7 @@ async function checkAllowedEdits(image) { // phrase the final reply (matching the user's language) and deliver the image // with that phrasing as its caption in one message. async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { - edits = expandPlaceholderEdits(edits); + edits = await expandPlaceholderEdits(edits); let elements; try { @@ -235,6 +237,6 @@ module.exports = { buildTopLevelEditOptions, buildDiwaliOfferCaption, MAX_DISCOUNT_PERCENT, - TV_PLACEHOLDER_IMAGE_URL, + TV_PRODUCT_SOURCE_IMAGE_URL, TV_PLACEHOLDER_IMAGE_TOKEN, }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 8bc9028c17..9e426a260d 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -5,6 +5,7 @@ const path = require('node:path'); const os = require('node:os'); const expressFlow = require('./expressFlow'); const expressApi = require('./express/expressApi'); +const s3Upload = require('./express/s3Upload'); const { findTrackedImage, recordEdits } = require('./imageStore'); const { parseEditOptionId } = require('./interactiveReply'); @@ -117,10 +118,15 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, and retu assert.deepEqual(updated.currentEdits, { cta: '20% off' }); }); -test('editGraphic expands the TV placeholder image token to the real S3 URL before calling generate-variation', async () => { +test('editGraphic expands the TV placeholder image token to a freshly signed S3 URL before calling generate-variation', async () => { + const signedUrl = 'https://bucket.s3.us-east-1.amazonaws.com/SonyTv.png?X-Amz-Signature=fake'; + s3Upload.uploadFromUrl = async (sourceUrl) => { + assert.equal(sourceUrl, expressFlow.TV_PRODUCT_SOURCE_IMAGE_URL); + return signedUrl; + }; expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; expressApi.generateVariation = async (docId, tagMappings) => { - assert.equal(tagMappings.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_URL); + assert.equal(tagMappings.productImage, signedUrl); assert.notEqual(tagMappings.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); return { jobId: 'job-tv', statusUrl: 'https://express-api.adobe.io/status/job-tv' }; }; @@ -132,7 +138,7 @@ test('editGraphic expands the TV placeholder image token to the real S3 URL befo assert.equal(result.status, 'success'); const updated = findTrackedImage('phone-tv', 'img_1'); - assert.equal(updated.currentEdits.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_URL); + assert.equal(updated.currentEdits.productImage, signedUrl); }); test('editGraphic returns an api_error/generate_failed status and does not record the edit when generation fails', async () => { diff --git a/yarn.lock b/yarn.lock index e4212e3db7..b4adcedc91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,288 @@ # yarn lockfile v1 +"@aws-sdk/checksums@^3.1000.19": + version "3.1000.19" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/checksums/-/checksums-3.1000.19.tgz#91d29b5fd576a42547c6da587541759a5b4981d3" + integrity sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/client-s3@^3.1094.0": + version "3.1094.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/client-s3/-/client-s3-3.1094.0.tgz#3cfdc562da7330785bf3a2ea8f16b29650e7e33b" + integrity sha512-Qkz3HXW9bBajTO1Pgvbxkj2THliDnYPFBaJwIF1mDmnPe1E7reV9V/QJxBz14slIvdcU0tiyB+z37Qns0EY9Zg== + dependencies: + "@aws-sdk/checksums" "^3.1000.19" + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-node" "^3.972.71" + "@aws-sdk/middleware-sdk-s3" "^3.972.65" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/core@^3.976.0": + version "3.976.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" + integrity sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@aws-sdk/xml-builder" "^3.972.36" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.29.4" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.60": + version "3.972.60" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" + integrity sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.62": + version "3.972.62" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" + integrity sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.973.5": + version "3.973.5" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" + integrity sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-login" "^3.972.67" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.67": + version "3.972.67" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" + integrity sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@^3.972.71": + version "3.972.71" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" + integrity sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-ini" "^3.973.5" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.60": + version "3.972.60" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" + integrity sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.973.4": + version "3.973.4" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" + integrity sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/token-providers" "3.1092.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.66": + version "3.972.66" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" + integrity sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-sdk-s3@^3.972.65": + version "3.972.65" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz#9b49df5deb68dee5d5da4d630a6ce3676408de5e" + integrity sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.34": + version "3.997.34" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" + integrity sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/s3-request-presigner@^3.1094.0": + version "3.1094.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1094.0.tgz#480b21fe5073cc6e08516ddc6898cd3c0cdd8b6b" + integrity sha512-7kG3C37tPWEpz8TyHslFhi2TrQ10und7ijerO0V/Ljq2uxBWVi4SFcol8yp2gM09HYzMnkWCfOxAKJZ2OZvClw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.41": + version "3.996.41" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" + integrity sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1092.0": + version "3.1092.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" + integrity sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/types@^3.974.2": + version "3.974.2" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.36": + version "3.972.36" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" + integrity sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws/lambda-invoke-store@^0.3.0": + version "0.3.0" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" + integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== + +"@smithy/core@^3.29.4", "@smithy/core@^3.29.8": + version "3.29.8" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/core/-/core-3.29.8.tgz#6466029ff27285257cf9bb4864fdb632c870ccf0" + integrity sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.4.9": + version "4.4.13" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.13.tgz#33dce458f03ee3a262b5379eff37e335eaea70a9" + integrity sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.6.6": + version "5.6.10" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.10.tgz#a50908b47d16a8f5c56e98762c2e27339a8c19ab" + integrity sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.9.6": + version "4.9.10" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/node-http-handler/-/node-http-handler-4.9.10.tgz#e03a874620cec491f787787b8bf6072d45adabc7" + integrity sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.6.5": + version "5.6.9" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/signature-v4/-/signature-v4-5.6.9.tgz#09ede51303925677a401f9759b18041ff46a1070" + integrity sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + accepts@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" @@ -25,6 +307,11 @@ body-parser@^2.2.1: raw-body "^3.0.2" type-is "^2.1.0" +bowser@^2.11.0: + version "2.14.1" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== + bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -464,6 +751,11 @@ toidentifier@~1.0.1: resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +tslib@^2.6.2: + version "2.8.1" + resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-is@^2.0.1, type-is@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" From 50301d7666d9b40e51019b11e204ad68c3fc0ccb Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:17:30 +0530 Subject: [PATCH 32/38] Fix/tv image s3 presign and discount percentage (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(express): update discountPercentage tag alongside price on discount edits Discount edits were only computing and setting price, leaving the document's discountPercentage tag stale even though the price had changed. Co-Authored-By: Claude Sonnet 5 * fix(express): auto-generate the TV product image S3 URL instead of a hardcoded one The previous presigned S3 URL for the TV placeholder image was hand-generated and expired after ~12h, requiring manual regeneration. Now the Scene7 source image is downloaded and re-uploaded to S3 on demand (ported from dynamicmedia-autoreflow's ImageS3Uploader), so the presigned URL is always fresh. Co-Authored-By: Claude Sonnet 5 * fix(deps): resolve new AWS SDK packages from the public npm registry yarn.lock pointed at Adobe's internal Artifactory host for the new @aws-sdk packages, picked up from this machine's global npm registry override. That's only reachable/trusted on Adobe's network — Render's build container has no CA trust for it, so `yarn install --frozen-lockfile` failed there with "unable to get local issuer certificate". Re-resolved against registry.yarnpkg.com to match the rest of the lockfile. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- yarn.lock | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/yarn.lock b/yarn.lock index b4adcedc91..fa1dde1ea1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,7 +4,7 @@ "@aws-sdk/checksums@^3.1000.19": version "3.1000.19" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/checksums/-/checksums-3.1000.19.tgz#91d29b5fd576a42547c6da587541759a5b4981d3" + resolved "https://registry.yarnpkg.com/@aws-sdk/checksums/-/checksums-3.1000.19.tgz#91d29b5fd576a42547c6da587541759a5b4981d3" integrity sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA== dependencies: "@aws-sdk/core" "^3.976.0" @@ -15,7 +15,7 @@ "@aws-sdk/client-s3@^3.1094.0": version "3.1094.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/client-s3/-/client-s3-3.1094.0.tgz#3cfdc562da7330785bf3a2ea8f16b29650e7e33b" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1094.0.tgz#3cfdc562da7330785bf3a2ea8f16b29650e7e33b" integrity sha512-Qkz3HXW9bBajTO1Pgvbxkj2THliDnYPFBaJwIF1mDmnPe1E7reV9V/QJxBz14slIvdcU0tiyB+z37Qns0EY9Zg== dependencies: "@aws-sdk/checksums" "^3.1000.19" @@ -32,7 +32,7 @@ "@aws-sdk/core@^3.976.0": version "3.976.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" integrity sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA== dependencies: "@aws-sdk/types" "^3.974.2" @@ -46,7 +46,7 @@ "@aws-sdk/credential-provider-env@^3.972.60": version "3.972.60" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" integrity sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg== dependencies: "@aws-sdk/core" "^3.976.0" @@ -57,7 +57,7 @@ "@aws-sdk/credential-provider-http@^3.972.62": version "3.972.62" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" integrity sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ== dependencies: "@aws-sdk/core" "^3.976.0" @@ -70,7 +70,7 @@ "@aws-sdk/credential-provider-ini@^3.973.5": version "3.973.5" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" integrity sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw== dependencies: "@aws-sdk/core" "^3.976.0" @@ -89,7 +89,7 @@ "@aws-sdk/credential-provider-login@^3.972.67": version "3.972.67" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" integrity sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ== dependencies: "@aws-sdk/core" "^3.976.0" @@ -101,7 +101,7 @@ "@aws-sdk/credential-provider-node@^3.972.71": version "3.972.71" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" integrity sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg== dependencies: "@aws-sdk/credential-provider-env" "^3.972.60" @@ -118,7 +118,7 @@ "@aws-sdk/credential-provider-process@^3.972.60": version "3.972.60" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" integrity sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw== dependencies: "@aws-sdk/core" "^3.976.0" @@ -129,7 +129,7 @@ "@aws-sdk/credential-provider-sso@^3.973.4": version "3.973.4" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" integrity sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA== dependencies: "@aws-sdk/core" "^3.976.0" @@ -142,7 +142,7 @@ "@aws-sdk/credential-provider-web-identity@^3.972.66": version "3.972.66" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" integrity sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng== dependencies: "@aws-sdk/core" "^3.976.0" @@ -154,7 +154,7 @@ "@aws-sdk/middleware-sdk-s3@^3.972.65": version "3.972.65" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz#9b49df5deb68dee5d5da4d630a6ce3676408de5e" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz#9b49df5deb68dee5d5da4d630a6ce3676408de5e" integrity sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg== dependencies: "@aws-sdk/core" "^3.976.0" @@ -166,7 +166,7 @@ "@aws-sdk/nested-clients@^3.997.34": version "3.997.34" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" integrity sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA== dependencies: "@aws-sdk/core" "^3.976.0" @@ -180,7 +180,7 @@ "@aws-sdk/s3-request-presigner@^3.1094.0": version "3.1094.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1094.0.tgz#480b21fe5073cc6e08516ddc6898cd3c0cdd8b6b" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1094.0.tgz#480b21fe5073cc6e08516ddc6898cd3c0cdd8b6b" integrity sha512-7kG3C37tPWEpz8TyHslFhi2TrQ10und7ijerO0V/Ljq2uxBWVi4SFcol8yp2gM09HYzMnkWCfOxAKJZ2OZvClw== dependencies: "@aws-sdk/core" "^3.976.0" @@ -192,7 +192,7 @@ "@aws-sdk/signature-v4-multi-region@^3.996.41": version "3.996.41" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" integrity sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng== dependencies: "@aws-sdk/types" "^3.974.2" @@ -202,7 +202,7 @@ "@aws-sdk/token-providers@3.1092.0": version "3.1092.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" integrity sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ== dependencies: "@aws-sdk/core" "^3.976.0" @@ -214,7 +214,7 @@ "@aws-sdk/types@^3.974.2": version "3.974.2" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== dependencies: "@smithy/types" "^4.16.1" @@ -222,7 +222,7 @@ "@aws-sdk/xml-builder@^3.972.36": version "3.972.36" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" integrity sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA== dependencies: "@smithy/types" "^4.16.1" @@ -230,12 +230,12 @@ "@aws/lambda-invoke-store@^0.3.0": version "0.3.0" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== "@smithy/core@^3.29.4", "@smithy/core@^3.29.8": version "3.29.8" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/core/-/core-3.29.8.tgz#6466029ff27285257cf9bb4864fdb632c870ccf0" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.29.8.tgz#6466029ff27285257cf9bb4864fdb632c870ccf0" integrity sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA== dependencies: "@smithy/types" "^4.16.1" @@ -243,7 +243,7 @@ "@smithy/credential-provider-imds@^4.4.9": version "4.4.13" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.13.tgz#33dce458f03ee3a262b5379eff37e335eaea70a9" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.13.tgz#33dce458f03ee3a262b5379eff37e335eaea70a9" integrity sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q== dependencies: "@smithy/core" "^3.29.8" @@ -252,7 +252,7 @@ "@smithy/fetch-http-handler@^5.6.6": version "5.6.10" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.10.tgz#a50908b47d16a8f5c56e98762c2e27339a8c19ab" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.10.tgz#a50908b47d16a8f5c56e98762c2e27339a8c19ab" integrity sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ== dependencies: "@smithy/core" "^3.29.8" @@ -261,7 +261,7 @@ "@smithy/node-http-handler@^4.9.6": version "4.9.10" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/node-http-handler/-/node-http-handler-4.9.10.tgz#e03a874620cec491f787787b8bf6072d45adabc7" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.10.tgz#e03a874620cec491f787787b8bf6072d45adabc7" integrity sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q== dependencies: "@smithy/core" "^3.29.8" @@ -270,7 +270,7 @@ "@smithy/signature-v4@^5.6.5": version "5.6.9" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/signature-v4/-/signature-v4-5.6.9.tgz#09ede51303925677a401f9759b18041ff46a1070" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.9.tgz#09ede51303925677a401f9759b18041ff46a1070" integrity sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw== dependencies: "@smithy/core" "^3.29.8" @@ -279,7 +279,7 @@ "@smithy/types@^4.16.1": version "4.16.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== dependencies: tslib "^2.6.2" @@ -309,7 +309,7 @@ body-parser@^2.2.1: bowser@^2.11.0: version "2.14.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== bytes@^3.1.2, bytes@~3.1.2: @@ -753,7 +753,7 @@ toidentifier@~1.0.1: tslib@^2.6.2: version "2.8.1" - resolved "https://artifactory.corp.adobe.com/artifactory/api/npm/npm-adobe-release/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== type-is@^2.0.1, type-is@^2.1.0: From 7a3f56d85d28b69e53263e350c2a73f5d86d6227 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:36:53 +0530 Subject: [PATCH 33/38] fix(express): request the Sony TV source image as png-alpha from Scene7 (#17) Appends &fmt=png-alpha to the Scene7 SonyTv URL so the downloaded source image keeps transparency before it's re-hosted on S3. Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/expressFlow.js | 2 +- src/interactiveReply.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index 2162750832..032a863812 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -20,7 +20,7 @@ const { formatAllowedEdits } = require('./editOptions'); // in the Express API spec). So it's re-hosted on S3 (upload + presigned GET URL, // via s3Upload.uploadFromUrl) at edit time instead of a one-off hand-uploaded URL // that itself expires and has to be manually regenerated. -const TV_PRODUCT_SOURCE_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000'; +const TV_PRODUCT_SOURCE_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000&fmt=png-alpha'; const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; // The presigned S3 URL is long — WhatsApp interactive list rows cap `id` at 200 diff --git a/src/interactiveReply.test.js b/src/interactiveReply.test.js index 04a62c7e6b..b6c3c8cf57 100644 --- a/src/interactiveReply.test.js +++ b/src/interactiveReply.test.js @@ -30,7 +30,7 @@ test('messageTextForInteractiveReply falls back to the title when the id is unpa }); test('buildValueEditId round-trips through parseEditOptionId', () => { - const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000', oldPrice: 33999, price: 27199 }; + const edits = { productImage: 'https://s7ap1.scene7.com/is/image/healthmonitor/SonyTv?wid=1000&fmt=png-alpha', oldPrice: 33999, price: 27199 }; const id = buildValueEditId('img_1', edits); const parsed = parseEditOptionId(id); assert.deepEqual(parsed, { imageId: 'img_1', edits }); From abeaf39bd1aa85a569b5026d936d84c4cf9a85af Mon Sep 17 00:00:00 2001 From: varun kalra Date: Mon, 27 Jul 2026 09:25:45 +0530 Subject: [PATCH 34/38] feat(local): emoji-prefixed Yes/No + edit buttons, slower generation pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow 2's Yes/No follow-ups and the "Change X" edit-menu button now carry emojis, matching the express flow's existing style. Plan-picker buttons stay plain text — prefixing them pushed titles like "Engine Protect combo" past WhatsApp's 20-char reply-button cap (#131009 API error). Also slows the streamed "generating..." progress messages (1800ms -> 3500ms per step) so the final banner takes longer to arrive and feels more like real generation work, and bumps the demo script's wait accordingly. --- demo-auto-flow.sh | 6 +++--- src/app.js | 7 +++++-- src/editOptions.js | 16 +++++++++++++++- src/localFlow.js | 2 +- src/localFlow.test.js | 2 +- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/demo-auto-flow.sh b/demo-auto-flow.sh index f14bf79414..94ad5a32de 100755 --- a/demo-auto-flow.sh +++ b/demo-auto-flow.sh @@ -11,7 +11,7 @@ set -euo pipefail BASE_URL="${BASE_URL:-http://localhost:3000}" PHONE="${PHONE:-919899860983}" PAUSE="${PAUSE:-10}" -GEN_PAUSE="${GEN_PAUSE:-25}" +GEN_PAUSE="${GEN_PAUSE:-30}" # Send a free-text WhatsApp message. send() { @@ -49,12 +49,12 @@ tap "3-Yr Comprehensive" sleep "$PAUSE" # 3) Yes to contact → WC asks "anything else?" [Yes / No, go ahead] -tap "Yes" +tap "✅ Yes" sleep "$PAUSE" # 4) Nothing else → WC streams progress for a few seconds, then sends the English banner. # Wait for that generation to finish before the next step so messages don't interleave. -tap "No, go ahead" +tap "➡️ No, go ahead" echo "... waiting ${GEN_PAUSE}s for generation to stream + finish ..." sleep "$GEN_PAUSE" diff --git a/src/app.js b/src/app.js index d2f8c6c4b4..b4fc6b4843 100644 --- a/src/app.js +++ b/src/app.js @@ -272,6 +272,9 @@ async function decideAction(phoneNumber, userMessage) { .join('\n'); // Approved plans offered by the personalised-offer flow (for the plan picker). + // No emoji here — plan names (e.g. "Engine Protect combo") already sit right at + // WhatsApp's 20-char reply-button title cap, and adding an emoji prefix pushes + // them over it, causing a #131009 "Button title length invalid" API error. const { plans } = getOfferContext(); const plansLine = plans.join(', '); @@ -285,8 +288,8 @@ If the request is ambiguous or missing details, use ask_for_more_information. If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. Creating a personalised customer offer (create_design) — this is for a car-dealership salesman making an on-brand offer to send to a specific customer (e.g. "create a personalised insurance offer for Apoorva who test drove the Grand Vitara"). Gather details first with tappable buttons, BEFORE creating: 1. Call ask_for_more_information asking which HQ-approved plan to feature, with options: [${plansLine}]. -2. Then call ask_for_more_information with options ["Yes","No"] asking "Should I add your name & number so can reach you directly?". -3. Then call ask_for_more_information with options ["Yes","No, go ahead"] asking "Anything else you'd like to add before I create it?". +2. Then call ask_for_more_information with options ["✅ Yes","🙅 No"] asking "Should I add your name & number so can reach you directly?". +3. Then call ask_for_more_information with options ["✅ Yes","➡️ No, go ahead"] asking "Anything else you'd like to add before I create it?". - Then call create_design with the customer's name, the model they were interested in, the chosen plan, and includeContact set from their contact answer. - To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. - Always attach options to any yes/no question so the salesman can tap a button instead of typing. diff --git a/src/editOptions.js b/src/editOptions.js index 6f994272f5..365468ac90 100644 --- a/src/editOptions.js +++ b/src/editOptions.js @@ -32,10 +32,24 @@ function truncateTitle(title, maxLength = 20) { return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; } +// Matches the emoji-prefixed style already used by the express flow's fixed +// Edit Product/Discount/Price menu (see expressFlow.TOP_LEVEL_EDIT_FIELDS). +const FIELD_EMOJIS = { + language: '🌐', + headline: '📝', + background: '🎨', + address: '📍', + offer: '🏷️', +}; + +function emojiForField(name) { + return FIELD_EMOJIS[name] || '✏️'; +} + function buildEditOptions(elements, imageId) { return elements.map((element) => ({ id: `edit:${imageId}:${element.name}`, - title: truncateTitle(`Change ${humanizeFieldName(element.name)}`), + title: truncateTitle(`${emojiForField(element.name)} Change ${humanizeFieldName(element.name)}`), })); } diff --git a/src/localFlow.js b/src/localFlow.js index 0c56f30d22..0feca18308 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -31,7 +31,7 @@ function sleep(ms) { // via GEN_STEP_DELAY_MS. async function streamProgress(sendText, phoneNumber, messages) { if (typeof sendText !== 'function') return; - const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1800); + const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 3500); for (const message of messages) { await sendText(phoneNumber, message); await sleep(delay); diff --git a/src/localFlow.test.js b/src/localFlow.test.js index 2880eaebbf..d56b9de132 100644 --- a/src/localFlow.test.js +++ b/src/localFlow.test.js @@ -120,7 +120,7 @@ test('checkAllowedEdits lists the editable slots from the schema', async () => { const result = localFlow.checkAllowedEdits(image); assert.equal(result.type, 'edit_options'); - assert.deepEqual(result.options.map((o) => o.title), ['Change language']); + assert.deepEqual(result.options.map((o) => o.title), ['🌐 Change language']); }); test('getOfferContext exposes the approved plans and featured governance', () => { From 4fc9e47431cfa9f5357c0fce31567fc6ee4fefd6 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:30:09 +0530 Subject: [PATCH 35/38] fix(express): serve product-change requests from a hardcoded banner, skip live Express/S3 calls (#19) Product-image swap requests (select_tv_model) now bypass the real Adobe Express generate-variation pipeline and S3 re-hosting entirely, returning a pre-rendered banner URL instead. The usual "calling Express API" progress message still streams to the user. Price/discount-only edits are unaffected and continue to hit the real Express API. Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/expressFlow.js | 22 ++++++++++++++++++++++ src/expressFlow.test.js | 35 +++++++++++++++++++---------------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/expressFlow.js b/src/expressFlow.js index 032a863812..369131d6c4 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -32,6 +32,13 @@ const TV_MODEL_TITLES = ['Sony Bravia K-75', 'LG UA82 AI', 'Samsung UA4']; const TV_PLACEHOLDER_IMAGE_TOKEN = 'tv-model-image-placeholder'; const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 33999, price: 27199 }; +// TEMP: product-change requests ("select_tv_model" / any edit carrying a +// productImage) are served from this pre-rendered banner instead of the real +// Adobe Express generate-variation pipeline — the user still sees the usual +// "calling Express API" progress message, but no Express/S3 network calls happen. +const HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL = + 'https://s7ap1.scene7.com/is/image/healthmonitor/Croma-Diwali-Banner-product-update?wid=1000'; + async function expandPlaceholderEdits(edits) { if (edits && edits.productImage === TV_PLACEHOLDER_IMAGE_TOKEN) { const signedUrl = await s3Upload.uploadFromUrl(TV_PRODUCT_SOURCE_IMAGE_URL); @@ -161,6 +168,20 @@ async function checkAllowedEdits(image) { // phrase the final reply (matching the user's language) and deliver the image // with that phrasing as its caption in one message. async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { + const isProductUpdate = Boolean(edits && Object.prototype.hasOwnProperty.call(edits, 'productImage')); + + if (isProductUpdate) { + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + + const mergedEdits = { ...image.currentEdits, ...edits }; + recordEdits(phoneNumber, image.id, edits); + + const outcome = { status: 'success', productName: image.name, changes: edits, thumbnailUrl: HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; + } + edits = await expandPlaceholderEdits(edits); let elements; @@ -239,4 +260,5 @@ module.exports = { MAX_DISCOUNT_PERCENT, TV_PRODUCT_SOURCE_IMAGE_URL, TV_PLACEHOLDER_IMAGE_TOKEN, + HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL, }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index 9e426a260d..a5790d8b7e 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -118,27 +118,30 @@ test('editGraphic applies an allowed edit end-to-end: generates, polls, and retu assert.deepEqual(updated.currentEdits, { cta: '20% off' }); }); -test('editGraphic expands the TV placeholder image token to a freshly signed S3 URL before calling generate-variation', async () => { - const signedUrl = 'https://bucket.s3.us-east-1.amazonaws.com/SonyTv.png?X-Amz-Signature=fake'; - s3Upload.uploadFromUrl = async (sourceUrl) => { - assert.equal(sourceUrl, expressFlow.TV_PRODUCT_SOURCE_IMAGE_URL); - return signedUrl; - }; - expressApi.getTaggedDocument = async () => TV_ELEMENTS_DOC; - expressApi.generateVariation = async (docId, tagMappings) => { - assert.equal(tagMappings.productImage, signedUrl); - assert.notEqual(tagMappings.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); - return { jobId: 'job-tv', statusUrl: 'https://express-api.adobe.io/status/job-tv' }; - }; - expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/tv-thumb.png' } }); +test('editGraphic serves a product-change request from the hardcoded banner and makes no Express/S3 calls', async () => { + s3Upload.uploadFromUrl = async () => { throw new Error('should not be called'); }; + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; const image = catalogImage('phone-tv'); + const sentTexts = []; const edits = { productImage: expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: '33999', price: '27199' }; - const result = await expressFlow.editGraphic('phone-tv', image, edits, {}); + const result = await expressFlow.editGraphic('phone-tv', image, edits, { + sendText: async (phone, text) => sentTexts.push([phone, text]), + }); + + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: edits, + thumbnailUrl: expressFlow.HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL, + price: '27199', + oldPrice: '33999', + }); + assert.deepEqual(sentTexts, [['phone-tv', '⏳ Applying your edit and re-rendering with Adobe Express…']]); - assert.equal(result.status, 'success'); const updated = findTrackedImage('phone-tv', 'img_1'); - assert.equal(updated.currentEdits.productImage, signedUrl); + assert.equal(updated.currentEdits.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); }); test('editGraphic returns an api_error/generate_failed status and does not record the edit when generation fails', async () => { From 4e0c2d228a6a7364f3d43f88dd7fc4abf0c870f6 Mon Sep 17 00:00:00 2001 From: priyankmodiPM <32540484+priyankmodiPM@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:45:23 +0530 Subject: [PATCH 36/38] fix(express): serve discount-change requests from a hardcoded banner (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "discountPercentage" (sent by GPT alongside the computed price per the system prompt) isn't a tagged document field, so every discount edit was being rejected as disallowed_fields before the discount-cap logic ever ran — e.g. "thik hai 40 hi kardo" always failed. editGraphic now recognizes discountPercentage explicitly: requests over the 40% cap still return discount_capped, and everything else returns the hardcoded Croma-Diwali-Banner-discount-update banner (same pattern as the earlier product-update bypass), still showing the usual Express progress message. Co-authored-by: Priyank Modi Co-authored-by: Claude Sonnet 5 --- src/expressFlow.js | 32 ++++++++++++++++++++++++++++++ src/expressFlow.test.js | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/expressFlow.js b/src/expressFlow.js index 369131d6c4..55f1d6df96 100644 --- a/src/expressFlow.js +++ b/src/expressFlow.js @@ -39,6 +39,14 @@ const TV_MODEL_EDITS = { productImage: TV_PLACEHOLDER_IMAGE_TOKEN, oldPrice: 339 const HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL = 'https://s7ap1.scene7.com/is/image/healthmonitor/Croma-Diwali-Banner-product-update?wid=1000'; +// TEMP: discount-change requests are served from this pre-rendered banner instead +// of the real Adobe Express pipeline. GPT sends a "discountPercentage" key (see the +// system prompt in app.js) alongside the computed price, but that key isn't one of +// the document's tagged elements — the real pipeline would always reject it as a +// disallowed field — so it's routed here instead, same as the product-change bypass. +const HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL = + 'https://s7ap1.scene7.com/is/image/healthmonitor/Croma-Diwali-Banner-discount-update?wid=1000'; + async function expandPlaceholderEdits(edits) { if (edits && edits.productImage === TV_PLACEHOLDER_IMAGE_TOKEN) { const signedUrl = await s3Upload.uploadFromUrl(TV_PRODUCT_SOURCE_IMAGE_URL); @@ -169,6 +177,7 @@ async function checkAllowedEdits(image) { // with that phrasing as its caption in one message. async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { const isProductUpdate = Boolean(edits && Object.prototype.hasOwnProperty.call(edits, 'productImage')); + const isDiscountUpdate = Boolean(edits && Object.prototype.hasOwnProperty.call(edits, 'discountPercentage')); if (isProductUpdate) { if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); @@ -182,6 +191,28 @@ async function editGraphic(phoneNumber, image, edits, { sendText } = {}) { return outcome; } + if (isDiscountUpdate) { + // discountPercentage isn't a tagged document field (only productImage/oldPrice/ + // price are) — drop it before recording so it doesn't taint currentEdits for a + // later real edit's tagMappings. + const { discountPercentage, ...taggedEdits } = edits; + + const requestedPercent = parsePercent(discountPercentage); + if (requestedPercent !== null && requestedPercent > MAX_DISCOUNT_PERCENT) { + return { status: 'discount_capped', productName: image.name, maxPercent: MAX_DISCOUNT_PERCENT }; + } + + if (typeof sendText === 'function') await sendText(phoneNumber, '⏳ Applying your edit and re-rendering with Adobe Express…'); + + const mergedEdits = { ...image.currentEdits, ...taggedEdits }; + recordEdits(phoneNumber, image.id, taggedEdits); + + const outcome = { status: 'success', productName: image.name, changes: taggedEdits, thumbnailUrl: HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL }; + if (mergedEdits.price !== undefined) outcome.price = mergedEdits.price; + if (mergedEdits.oldPrice !== undefined) outcome.oldPrice = mergedEdits.oldPrice; + return outcome; + } + edits = await expandPlaceholderEdits(edits); let elements; @@ -261,4 +292,5 @@ module.exports = { TV_PRODUCT_SOURCE_IMAGE_URL, TV_PLACEHOLDER_IMAGE_TOKEN, HARDCODED_PRODUCT_UPDATE_THUMBNAIL_URL, + HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL, }; diff --git a/src/expressFlow.test.js b/src/expressFlow.test.js index a5790d8b7e..cc8da5cffa 100644 --- a/src/expressFlow.test.js +++ b/src/expressFlow.test.js @@ -144,6 +144,49 @@ test('editGraphic serves a product-change request from the hardcoded banner and assert.equal(updated.currentEdits.productImage, expressFlow.TV_PLACEHOLDER_IMAGE_TOKEN); }); +test('editGraphic serves a discount-change request from the hardcoded banner and makes no Express calls, stripping discountPercentage before recording', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-discount'); + const sentTexts = []; + + const edits = { price: 20399, discountPercentage: '40%' }; + const result = await expressFlow.editGraphic('phone-discount', image, edits, { + sendText: async (phone, text) => sentTexts.push([phone, text]), + }); + + assert.deepEqual(result, { + status: 'success', + productName: 'Croma Earbuds', + changes: { price: 20399 }, + thumbnailUrl: expressFlow.HARDCODED_DISCOUNT_UPDATE_THUMBNAIL_URL, + price: 20399, + }); + assert.deepEqual(sentTexts, [['phone-discount', '⏳ Applying your edit and re-rendering with Adobe Express…']]); + + const updated = findTrackedImage('phone-discount', 'img_1'); + assert.deepEqual(updated.currentEdits, { price: 20399 }); + assert.ok(!('discountPercentage' in updated.currentEdits)); +}); + +test('editGraphic caps a discount-change request above 40% without calling Express', async () => { + expressApi.getTaggedDocument = async () => { throw new Error('should not be called'); }; + expressApi.generateVariation = async () => { throw new Error('should not be called'); }; + const image = catalogImage('phone-discount-2'); + + const result = await expressFlow.editGraphic( + 'phone-discount-2', + image, + { price: 16999, discountPercentage: '50%' }, + {} + ); + + assert.deepEqual(result, { status: 'discount_capped', productName: 'Croma Earbuds', maxPercent: 40 }); + + const updated = findTrackedImage('phone-discount-2', 'img_1'); + assert.deepEqual(updated.currentEdits, {}); +}); + test('editGraphic returns an api_error/generate_failed status and does not record the edit when generation fails', async () => { expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; From 0677190011f21d0a8bcefabe8caeefb9ba905e6c Mon Sep 17 00:00:00 2001 From: varun kalra Date: Mon, 27 Jul 2026 21:50:57 +0530 Subject: [PATCH 37/38] feat(flow2): deterministic, phone-gated personalised-offer flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take the plan → contact → confirm → create sequence out of the LLM's hands and drive it from a code state machine, so the questions always get asked in order instead of the model sometimes jumping straight to creating. - flow2Session.js: pure state machine (start/advance) returning ask/reask/create descriptors; app.js owns the WhatsApp side effects. While a session is active the LLM is bypassed entirely. - fuzzy.js: typo-tolerant matching (Levenshtein) for plan and yes/no answers, so a tapped button OR a mistyped reply both resolve; unmatched answers re-ask. - Gate Flow 2 to a single demo phone (FLOW2_PHONE, default 9899860983, substring match). Other numbers are restricted to the current behaviour (Flow 1 + generic tools); a stray create_design there is politely declined. - Deterministic fuzzy trigger (isCreateOfferIntent) detects a "create a personalised offer" message without the LLM and (re)starts the session from step 1 — checked before the in-progress bypass so a re-prompt restarts cleanly. - extractOffer pulls customer/model from the trigger text, falling back to defaults in offer-design.json. - The LLM's only Flow-2 role is now intent detection + entity extraction; system prompt and create_design schema updated to match. Flow 1 logic is untouched; it's now safer since arbitrary numbers can't trigger Flow 2. Adds fuzzy.test.js + flow2Session.test.js (82 tests pass). --- data/offer-design.json | 2 + src/app.js | 121 ++++++++++++++++++++++------ src/flow2Session.js | 166 +++++++++++++++++++++++++++++++++++++++ src/flow2Session.test.js | 116 +++++++++++++++++++++++++++ src/fuzzy.js | 120 ++++++++++++++++++++++++++++ src/fuzzy.test.js | 55 +++++++++++++ src/localFlow.js | 11 ++- 7 files changed, 562 insertions(+), 29 deletions(-) create mode 100644 src/flow2Session.js create mode 100644 src/flow2Session.test.js create mode 100644 src/fuzzy.js create mode 100644 src/fuzzy.test.js diff --git a/data/offer-design.json b/data/offer-design.json index f5533d158a..9f8fe947f6 100644 --- a/data/offer-design.json +++ b/data/offer-design.json @@ -11,6 +11,8 @@ "was": "₹52,499/year", "savings": "₹23,500/year" }, + "defaults": { "customer": "Apoorva", "model": "Grand Vitara" }, + "models": ["Grand Vitara", "Brezza", "Swift", "Baleno", "Ertiga", "Fronx", "Jimny", "Dzire", "Ciaz", "XL6"], "slots": { "editable": [ { "name": "language", "type": "text", "aliases": ["lang", "translate", "translation", "headline", "text"] } diff --git a/src/app.js b/src/app.js index b4fc6b4843..a9aa3a8415 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ const { getOfferContext, } = require('./actions'); const { parseEditOptionId, messageTextForInteractiveReply } = require('./interactiveReply'); +const flow2Session = require('./flow2Session'); const app = express(); app.use(express.json()); @@ -29,6 +30,20 @@ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: openaiB // In-memory conversation history per phone number (last 20 messages kept) const conversationHistory = new Map(); +// Active Flow-2 gathering sessions, keyed by phone number. While a session is +// present, inbound messages are handled deterministically by the flow2Session +// state machine (plan → contact → confirm → create) and the LLM is bypassed — +// that's what makes the question sequence deterministic. See flow2Session.js. +const flow2Sessions = new Map(); + +// Flow 2 (the personalised-offer flow) is gated to a single demo phone number so +// arbitrary inbound numbers can't trigger it — everyone else is restricted to the +// existing behaviour (Flow 1 + generic tools). Substring match, env-overridable. +const FLOW2_PHONE = process.env.FLOW2_PHONE || '9899860983'; +function isFlow2Phone(phoneNumber) { + return String(phoneNumber).includes(FLOW2_PHONE); +} + function getHistory(phoneNumber) { return conversationHistory.get(phoneNumber) || []; } @@ -139,6 +154,42 @@ function sendQuickReplies(to, question, options) { return sendButtons(to, question, buttons); } +// ── Flow 2 deterministic sequence executor ─────────────────────────────────── + +// Execute a descriptor returned by flow2Session (ask/reask a question, or create +// the design). Keeps all the WhatsApp side effects here; flow2Session stays pure. +async function runFlow2Action(phoneNumber, action) { + if (!action) return; + if (action.type === 'ask' || action.type === 'reask') { + await sendQuickReplies(phoneNumber, action.question, action.options); + appendHistory(phoneNumber, 'assistant', action.question); + return; + } + if (action.type === 'create') { + console.log('[flow2] create_design (deterministic)', action.args); + const result = await actionCreateDesign(phoneNumber, action.args, { sendImage, sendText }); + if (typeof result === 'string') { + // Error path — the flow couldn't send the image, so surface the message. + await sendText(phoneNumber, result); + appendHistory(phoneNumber, 'assistant', result); + } else { + // Success — image + caption already sent by the flow. + appendHistory(phoneNumber, 'assistant', result.historyText); + } + } +} + +// Handle one inbound message while a Flow-2 session is active. Fully deterministic: +// no LLM call — the answer is fuzzy-matched and the state machine picks the next step. +async function handleFlow2Turn(phoneNumber, userText) { + const { plans } = getOfferContext(); + const session = flow2Sessions.get(phoneNumber); + const { state, action } = flow2Session.advance(session, userText, { plans }); + if (state) flow2Sessions.set(phoneNumber, state); + else flow2Sessions.delete(phoneNumber); + await runFlow2Action(phoneNumber, action); +} + // ── GPT tool definitions ───────────────────────────────────────────────────── const tools = [ @@ -155,17 +206,12 @@ const tools = [ function: { name: 'create_design', description: - 'Create a brand-new PERSONALISED offer creative for a specific customer (e.g. a personalised car-insurance offer for a customer who test drove a model). Call this only AFTER gathering the plan and contact details via ask_for_more_information. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', + 'Signal that the user wants a brand-new PERSONALISED offer creative for a specific customer (e.g. a personalised car-insurance offer for a customer who test drove a model). Call this as SOON as you recognise that intent, passing only customer and model. The system then gathers the plan and contact details with tappable buttons and drives the rest of the sequence — so do NOT pass plan or includeContact, and do NOT ask any questions first. Do NOT use this for bulk generation from a CSV/Excel file — that is generate_bulk_graphics.', parameters: { type: 'object', properties: { customer: { type: 'string', description: "The customer's name, e.g. \"Apoorva\"" }, model: { type: 'string', description: "The vehicle/product the customer is interested in, e.g. \"Grand Vitara\"" }, - plan: { type: 'string', description: "The chosen HQ-approved plan to feature, e.g. \"3-Yr Comprehensive\"" }, - includeContact: { - type: 'boolean', - description: "Set true if the salesman wants their name & number added as the contact on the creative.", - }, }, required: [], }, @@ -271,12 +317,9 @@ async function decideAction(phoneNumber, userMessage) { .map((image) => `- ${image.id}: ${image.name}${formatCurrentEdits(image.currentEdits)}`) .join('\n'); - // Approved plans offered by the personalised-offer flow (for the plan picker). - // No emoji here — plan names (e.g. "Engine Protect combo") already sit right at - // WhatsApp's 20-char reply-button title cap, and adding an emoji prefix pushes - // them over it, causing a #131009 "Button title length invalid" API error. - const { plans } = getOfferContext(); - const plansLine = plans.join(', '); + // Note: the personalised-offer plan picker is no longer driven from this prompt — + // the deterministic Flow-2 state machine (flow2Session.js) asks for the plan with + // tappable buttons after create_design fires, so the plans list isn't needed here. const messages = [ { @@ -286,13 +329,10 @@ Analyze the user's message and conversation history, then call the appropriate t Always call exactly one tool — never reply with plain text. If the request is ambiguous or missing details, use ask_for_more_information. If the user says which field they want to change but hasn't given the new value yet, call ask_for_more_information to ask what to change it to. If a later message in the conversation then supplies that value, call edit_graphic with the field and value instead of asking again. -Creating a personalised customer offer (create_design) — this is for a car-dealership salesman making an on-brand offer to send to a specific customer (e.g. "create a personalised insurance offer for Apoorva who test drove the Grand Vitara"). Gather details first with tappable buttons, BEFORE creating: -1. Call ask_for_more_information asking which HQ-approved plan to feature, with options: [${plansLine}]. -2. Then call ask_for_more_information with options ["✅ Yes","🙅 No"] asking "Should I add your name & number so can reach you directly?". -3. Then call ask_for_more_information with options ["✅ Yes","➡️ No, go ahead"] asking "Anything else you'd like to add before I create it?". -- Then call create_design with the customer's name, the model they were interested in, the chosen plan, and includeContact set from their contact answer. +Creating a personalised customer offer (create_design) — this is for a car-dealership salesman making an on-brand offer to send to a specific customer (e.g. "Apoorva test drove the Grand Vitara and asked about insurance — make her a personalised offer"). The message may contain typos. +- As SOON as you recognise this intent, call create_design immediately, passing only the customer's name and the model you can extract from the conversation. If either is unclear, still call create_design with whatever you have (or leave it blank). +- Do NOT ask about the plan, the contact, or "anything else" yourself, and do NOT call ask_for_more_information for this flow — the system gathers the plan and contact with tappable buttons and drives the rest of the sequence deterministically after create_design is called. Never pass plan or includeContact yourself. - To translate the offer to another language (e.g. "make it in Hindi"), call edit_graphic — the offer is available in English and Hindi. -- Always attach options to any yes/no question so the salesman can tap a button instead of typing. Choosing between edit_graphic and check_allowed_edits: if the user's message already contains a concrete change and its value (e.g. "make the background marigold", "add my address MG Road Kochi"), call edit_graphic with all of those changes in the edits object. Only call check_allowed_edits when the user asks what can be changed or wants the list of options WITHOUT giving a specific value. When editing, prefer these field names when they apply: headline, background, address, offer. If the user asks to translate a tag's text into another language (e.g. "change the headline to Hindi", "translate the banner to Malayalam"), translate the current text yourself before calling edit_graphic and pass the translated text as the edit value. For Hindi, use Devanagari script (e.g. "उपलब्ध"); for Malayalam, use Malayalam script (e.g. "ഓണം"). Never use a romanized/transliterated form. @@ -382,6 +422,26 @@ app.post('/', async (req, res) => { appendHistory(phoneNumber, 'user', userText); + // Flow 2 is gated to the demo phone. On that phone, a fresh "create a + // personalised offer" message (fuzzy-matched, typo-tolerant) (re)starts the + // gathering session from step 1 — checked BEFORE the in-progress bypass so a + // re-prompt always restarts rather than being read as an answer. + if (isFlow2Phone(phoneNumber) && flow2Session.isCreateOfferIntent(userText)) { + const { plans, defaults, models } = getOfferContext(); + const offerArgs = flow2Session.extractOffer(userText, { defaults, models }); + const { state, action } = flow2Session.start(offerArgs, { plans }); + flow2Sessions.set(phoneNumber, state); + console.log('[flow2] (re)start via fuzzy trigger', offerArgs); + await runFlow2Action(phoneNumber, action); + continue; + } + + // Flow-2 gathering in progress → drive it deterministically, skip the LLM. + if (flow2Sessions.has(phoneNumber)) { + await handleFlow2Turn(phoneNumber, userText); + continue; + } + const gptMessage = await decideAction(phoneNumber, userText); const toolCall = gptMessage.tool_calls?.[0]; if (!toolCall) continue; @@ -400,15 +460,24 @@ app.post('/', async (req, res) => { break; case 'create_design': { - // Progress is streamed from inside the flow (with the product/offer context). - // On success the flow already sent the image+caption, so skip the extra text. - const result = await actionCreateDesign(phoneNumber, args, { sendImage, sendText }); - if (typeof result === 'string') { - replyText = result; - } else { - replyText = result.historyText; - skipSend = true; + // Fallback Flow-2 entry: the deterministic fuzzy trigger above catches + // most create requests before the LLM runs, but this handles phrasings it + // missed. Gated to the demo phone — other numbers are restricted to the + // current behaviour, so a stray create_design there is politely declined. + if (!isFlow2Phone(phoneNumber)) { + replyText = "I can help you update your existing campaign graphics — tell me what you'd like to change."; + break; } + // The LLM only DETECTS intent + extracts customer/model; it never creates + // directly. Starting a session hands sequencing to the deterministic state + // machine (plan → contact → confirm, fuzzy-matched) before create_design + // ever runs — the model can't skip the questions. + const { plans, defaults } = getOfferContext(); + const startArgs = { customer: args.customer || defaults.customer, model: args.model || defaults.model }; + const { state, action } = flow2Session.start(startArgs, { plans }); + flow2Sessions.set(phoneNumber, state); + await runFlow2Action(phoneNumber, action); + skipSend = true; break; } diff --git a/src/flow2Session.js b/src/flow2Session.js new file mode 100644 index 0000000000..64fd43d014 --- /dev/null +++ b/src/flow2Session.js @@ -0,0 +1,166 @@ +// ── Flow 2: deterministic gathering sequence ───────────────────────────────── +// The plan → contact → confirm → create sequence is driven HERE, in code — not by +// the LLM. Previously the LLM was asked to volunteer these questions before +// create_design, which it did inconsistently (a complete-sounding opening message +// made it skip straight to creating). Now the LLM's only job for this flow is to +// detect the intent and extract customer/model; once that fires, this state machine +// owns every subsequent step, so the questions ALWAYS get asked, in order. +// +// Pure logic: start()/advance() take the current session + the user's (fuzzy-matched) +// answer and return { state, action }. `state` is the next session state (null when +// the sequence is complete); `action` is a descriptor the caller executes: +// { type: 'ask' | 'reask', question, options } → send tappable buttons +// { type: 'create', args: { customer, model, plan, includeContact } } → build it +// The caller never has to know the step order. + +const { matchOption, matchYesNo, normalize, fuzzyEq } = require('./fuzzy'); + +// ── Deterministic "create a personalised offer" intent detection ───────────── +// Detects the Flow-2 trigger from free text WITHOUT the LLM, tolerant of typos +// ("crate a personalsied bannr"), so the gated demo phone can (re)start the +// session on a matching message. Kept distinct from Flow-1 edit phrasing: the +// trigger needs an explicit "make/create/design …" + "banner/offer/creative", +// whereas Flow-1 edits say "change/edit the price/discount/product". +const ACTION_WORDS = ['create', 'make', 'design', 'generate', 'build', 'prepare', 'craft']; +const ARTIFACT_WORDS = ['banner', 'offer', 'creative', 'poster', 'graphic', 'flyer', 'design']; + +function isCreateOfferIntent(text) { + const toks = normalize(text).split(' ').filter(Boolean); + if (toks.length === 0) return false; + const hasAction = toks.some((t) => ACTION_WORDS.some((a) => fuzzyEq(t, a))); + const hasArtifact = toks.some((t) => ARTIFACT_WORDS.some((a) => fuzzyEq(t, a))); + const hasPersonalised = toks.some((t) => fuzzyEq(t, 'personalised') || fuzzyEq(t, 'personalized')); + return (hasAction && hasArtifact) || (hasPersonalised && hasArtifact); +} + +// Words that can look like a name but never are (sentence starters / verbs). +const NOT_A_NAME = new Set([ + 'i', 'she', 'he', 'they', 'we', 'you', 'the', 'a', 'an', 'my', 'her', 'his', 'their', + 'make', 'create', 'design', 'help', 'please', 'hi', 'hello', 'hey', 'can', 'could', + 'would', 'need', 'want', 'also', 'and', 'but', 'so', 'yesterday', 'today', +]); + +function extractCustomer(text, fallback) { + const capitalised = String(text).match(/\b[A-Z][a-z]+\b/g) || []; + for (const w of capitalised) { + if (!NOT_A_NAME.has(w.toLowerCase())) return w; + } + return fallback || null; +} + +function extractModel(text, models, fallback) { + const n = normalize(text); + for (const m of models) { + const nm = normalize(m); + if (!nm) continue; + if (n.includes(nm)) return m; // full name present, e.g. "grand vitara" + const words = nm.split(' '); + const key = words[words.length - 1]; // distinctive last word, e.g. "vitara" + if (key.length >= 3 && n.split(' ').some((t) => fuzzyEq(t, key))) return m; + } + return fallback || null; +} + +// Best-effort deterministic extraction of customer + model from the trigger text, +// falling back to the configured demo defaults when nothing is found. +function extractOffer(text, { defaults = {}, models = [] } = {}) { + return { + customer: extractCustomer(text, defaults.customer), + model: extractModel(text, models, defaults.model), + }; +} + +const STEP = { + PLAN: 'awaiting_plan', + CONTACT: 'awaiting_contact', + CONFIRM: 'awaiting_confirm', +}; + +const YES_NO = ['✅ Yes', '🙅 No']; +const CONFIRM_OPTIONS = ['✅ Yes', '➡️ No, go ahead']; + +function who(state) { + return state.customer || 'your customer'; +} + +function askPlan(plans) { + return { type: 'ask', step: STEP.PLAN, question: 'Which HQ-approved plan should I feature?', options: plans }; +} + +function askContact(state) { + return { + type: 'ask', + step: STEP.CONTACT, + question: `Should I add your name & number so ${who(state)} can reach you directly?`, + options: YES_NO, + }; +} + +function askConfirm() { + return { + type: 'ask', + step: STEP.CONFIRM, + question: "Anything else you'd like to add before I create it?", + options: CONFIRM_OPTIONS, + }; +} + +// Begin a session from the LLM-extracted customer/model. First question is the plan. +function start({ customer, model } = {}, { plans } = {}) { + const state = { + step: STEP.PLAN, + customer: customer || null, + model: model || null, + plan: null, + includeContact: null, + }; + return { state, action: askPlan(plans) }; +} + +// Advance the session by one user answer. Fuzzy-matches the answer for the current +// step; on a confident match it moves forward, otherwise it re-asks the same +// question (never silently misfires). +function advance(state, userText, { plans } = {}) { + switch (state.step) { + case STEP.PLAN: { + const plan = matchOption(userText, plans); + if (!plan) { + return { state, action: { type: 'reask', step: STEP.PLAN, question: "Sorry, I didn't catch that — please pick a plan:", options: plans } }; + } + const next = { ...state, plan, step: STEP.CONTACT }; + return { state: next, action: askContact(next) }; + } + + case STEP.CONTACT: { + const yn = matchYesNo(userText); + if (yn === null) { + return { state, action: { type: 'reask', step: STEP.CONTACT, question: 'Just tap Yes or No — add your name & number?', options: YES_NO } }; + } + const next = { ...state, includeContact: yn === 'yes', step: STEP.CONFIRM }; + return { state: next, action: askConfirm() }; + } + + case STEP.CONFIRM: { + const yn = matchYesNo(userText); + if (yn === null) { + return { state, action: { type: 'reask', step: STEP.CONFIRM, question: 'Tap "No, go ahead" to create it now, or "Yes" if you want to add something.', options: CONFIRM_OPTIONS } }; + } + // Either answer proceeds to creation: the canned banner has nothing further to + // gather, and the success caption already invites post-creation edits ("Want to + // change anything?"). The question is kept for the scripted demo feel. + return { + state: null, + action: { + type: 'create', + args: { customer: state.customer, model: state.model, plan: state.plan, includeContact: state.includeContact }, + }, + }; + } + + default: + // Unknown step — abandon the session rather than loop. + return { state: null, action: null }; + } +} + +module.exports = { start, advance, STEP, isCreateOfferIntent, extractOffer }; diff --git a/src/flow2Session.test.js b/src/flow2Session.test.js new file mode 100644 index 0000000000..7916fedd22 --- /dev/null +++ b/src/flow2Session.test.js @@ -0,0 +1,116 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const flow2Session = require('./flow2Session'); + +const PLANS = ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo']; +const ctx = { plans: PLANS }; + +test('start asks the plan first, carrying customer/model into state', () => { + const { state, action } = flow2Session.start({ customer: 'Apoorva', model: 'Grand Vitara' }, ctx); + assert.equal(state.step, 'awaiting_plan'); + assert.equal(state.customer, 'Apoorva'); + assert.equal(state.model, 'Grand Vitara'); + assert.equal(action.type, 'ask'); + assert.deepEqual(action.options, PLANS); +}); + +test('full happy path: plan → contact → confirm → create (deterministic order)', () => { + let { state, action } = flow2Session.start({ customer: 'Apoorva', model: 'Grand Vitara' }, ctx); + assert.equal(action.step, 'awaiting_plan'); + + // plan (typed with a typo) + ({ state, action } = flow2Session.advance(state, 'comprehesive', ctx)); + assert.equal(state.step, 'awaiting_contact'); + assert.equal(state.plan, '3-Yr Comprehensive'); + assert.match(action.question, /reach you directly/); + + // contact (emoji button tap) + ({ state, action } = flow2Session.advance(state, '✅ Yes', ctx)); + assert.equal(state.step, 'awaiting_confirm'); + assert.equal(state.includeContact, true); + + // confirm → create + ({ state, action } = flow2Session.advance(state, '➡️ No, go ahead', ctx)); + assert.equal(state, null, 'session complete'); + assert.equal(action.type, 'create'); + assert.deepEqual(action.args, { + customer: 'Apoorva', + model: 'Grand Vitara', + plan: '3-Yr Comprehensive', + includeContact: true, + }); +}); + +test('contact answer "No" sets includeContact false', () => { + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, '3-Yr Comprehensive', ctx)); + const { state: next } = flow2Session.advance(state, 'nope', ctx); + assert.equal(next.includeContact, false); +}); + +test('unmatched plan re-asks the same step without advancing', () => { + const { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + const { state: next, action } = flow2Session.advance(state, 'zzz gibberish', ctx); + assert.equal(next.step, 'awaiting_plan', 'stays on plan step'); + assert.equal(action.type, 'reask'); +}); + +test('unmatched contact answer re-asks, does not assume', () => { + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, 'Zero Dep + RSA', ctx)); + const { state: next, action } = flow2Session.advance(state, 'hmm not sure', ctx); + assert.equal(next.step, 'awaiting_contact'); + assert.equal(action.type, 'reask'); +}); + +test('isCreateOfferIntent detects the trigger, including with typos', () => { + assert.equal( + flow2Session.isCreateOfferIntent('Apoorva visited my store yesterday and took a vitara test drive. she asked for insurance offers. help me crate a personalsied banner for her'), + true + ); + assert.equal(flow2Session.isCreateOfferIntent('make a personalised offer for Priya'), true); + assert.equal(flow2Session.isCreateOfferIntent('design a banner'), true); +}); + +test('isCreateOfferIntent does NOT fire on Flow-1 edits or post-create replies', () => { + assert.equal(flow2Session.isCreateOfferIntent('change the discount to 40%'), false); + assert.equal(flow2Session.isCreateOfferIntent('what can I edit'), false); + assert.equal(flow2Session.isCreateOfferIntent('make it in Hindi'), false); + assert.equal(flow2Session.isCreateOfferIntent('3-Yr Comprehensive'), false); + assert.equal(flow2Session.isCreateOfferIntent('✅ Yes'), false); +}); + +const MODELS = ['Grand Vitara', 'Brezza', 'Swift']; +const DEFAULTS = { customer: 'Apoorva', model: 'Grand Vitara' }; + +test('extractOffer pulls customer + model from the trigger text', () => { + const out = flow2Session.extractOffer( + 'Priya took a Brezza test drive, make her a personalised banner', + { defaults: DEFAULTS, models: MODELS } + ); + assert.equal(out.customer, 'Priya'); + assert.equal(out.model, 'Brezza'); +}); + +test('extractOffer resolves a distinctive model word ("vitara" → "Grand Vitara")', () => { + const out = flow2Session.extractOffer('Apoorva test drove the vitara', { defaults: DEFAULTS, models: MODELS }); + assert.equal(out.customer, 'Apoorva'); + assert.equal(out.model, 'Grand Vitara'); +}); + +test('extractOffer falls back to defaults when nothing is found', () => { + const out = flow2Session.extractOffer('make me a personalised banner', { defaults: DEFAULTS, models: MODELS }); + assert.equal(out.customer, 'Apoorva'); + assert.equal(out.model, 'Grand Vitara'); +}); + +test('confirm proceeds to create on either recognised answer', () => { + // "Yes, I have something to add" still creates (canned banner; edits are post-create) + let { state } = flow2Session.start({ customer: 'A', model: 'M' }, ctx); + ({ state } = flow2Session.advance(state, 'Engine Protect combo', ctx)); + ({ state } = flow2Session.advance(state, 'No', ctx)); + const { state: done, action } = flow2Session.advance(state, '✅ Yes', ctx); + assert.equal(done, null); + assert.equal(action.type, 'create'); + assert.equal(action.args.plan, 'Engine Protect combo'); +}); diff --git a/src/fuzzy.js b/src/fuzzy.js new file mode 100644 index 0000000000..20507fb7db --- /dev/null +++ b/src/fuzzy.js @@ -0,0 +1,120 @@ +// Typo-tolerant matching for the deterministic Flow-2 gathering sequence +// (see flow2Session.js). The salesman may TAP a button (exact title flows back) +// or TYPE a reply with mistakes ("comprehesive", "zero dep rsa", "yess", "nope") — +// both must resolve to the same canonical answer without any LLM call, so the +// step sequencing stays deterministic. + +// Classic Levenshtein edit distance (iterative two-row DP). +function levenshtein(a, b) { + a = String(a); + b = String(b); + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + let curr = new Array(b.length + 1); + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} + +// Lowercase, strip punctuation/emoji to spaces, collapse whitespace. This is what +// makes an emoji-prefixed button title ("✅ Yes", "Zero Dep + RSA") comparable to +// a plain typed answer. +function normalize(s) { + return String(s) + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Two tokens are "close enough" if their edit distance is a small fraction of the +// longer token — ~1 typo per 3 chars. +function fuzzyEq(a, b, maxRatio = 0.34) { + if (a === b) return true; + const d = levenshtein(a, b); + return d / Math.max(a.length, b.length) <= maxRatio; +} + +const SIGNIFICANT_MIN_LEN = 3; + +function significantTokens(normalized) { + return normalized.split(' ').filter((t) => t.length >= SIGNIFICANT_MIN_LEN); +} + +// Resolve a free-text answer to one of a closed set of options (e.g. the HQ plans), +// tolerating typos, partial phrases, and distinctive-word shorthand. Returns the +// matched option string, or null when nothing is confidently close. +function matchOption(input, options) { + const ni = normalize(input); + if (!ni) return null; + + // 1) exact (covers a button tap, whose title flows back verbatim) + for (const o of options) if (normalize(o) === ni) return o; + + // 2) full-phrase containment either direction ("engine protect combo" vs a + // longer title, or a typed superset of a short option) + for (const o of options) { + const no = normalize(o); + if (ni.length >= SIGNIFICANT_MIN_LEN && (no.includes(ni) || ni.includes(no))) return o; + } + + // 3) a distinctive word that uniquely identifies one option ("comprehensive", + // "rsa", "engine") — even with a typo in that word. + const itoks = significantTokens(ni); + for (const it of itoks) { + const owners = options.filter((o) => + significantTokens(normalize(o)).some((ot) => fuzzyEq(it, ot)) + ); + if (owners.length === 1) return owners[0]; + } + + // 4) fall back to majority token overlap (at least half an option's significant + // words fuzzy-matched by the input). + let best = null; + let bestScore = 0; + for (const o of options) { + const otoks = significantTokens(normalize(o)); + if (otoks.length === 0) continue; + let hits = 0; + for (const ot of otoks) if (itoks.some((it) => fuzzyEq(it, ot))) hits++; + const score = hits / otoks.length; + if (score > bestScore) { + bestScore = score; + best = o; + } + } + return bestScore >= 0.5 ? best : null; +} + +const YES_WORDS = ['yes', 'y', 'ya', 'yaa', 'yah', 'yeah', 'yep', 'yup', 'sure', 'ok', 'okay', 'okey', 'haan', 'han', 'ha', 'add', 'yess', 'yesss']; +const NO_WORDS = ['no', 'n', 'nope', 'nah', 'na', 'naa', 'skip', 'dont']; +// Phrases that mean "stop asking, just create it" — treated as a 'no' (nothing to add). +const PROCEED_PHRASES = ['go ahead', 'goahead', 'go head', 'proceed', 'create it', 'make it', 'nothing', 'no thanks', 'all good', 'looks good', 'ready']; + +// Resolve a free-text answer to 'yes' | 'no' | null (ambiguous / unrecognised). +// Typo-tolerant and emoji-safe. Callers decide what yes/no means per question. +function matchYesNo(input) { + const n = normalize(input); + if (!n) return null; + + if (PROCEED_PHRASES.some((p) => n.includes(p))) return 'no'; + + const toks = n.split(' '); + const isYes = toks.some((t) => YES_WORDS.includes(t) || fuzzyEq(t, 'yes') || fuzzyEq(t, 'yeah') || fuzzyEq(t, 'sure')); + const isNo = toks.some((t) => NO_WORDS.includes(t) || fuzzyEq(t, 'no', 0.5) || fuzzyEq(t, 'nope')); + + if (isYes && !isNo) return 'yes'; + if (isNo && !isYes) return 'no'; + return null; +} + +module.exports = { levenshtein, normalize, fuzzyEq, matchOption, matchYesNo }; diff --git a/src/fuzzy.test.js b/src/fuzzy.test.js new file mode 100644 index 0000000000..e287864aac --- /dev/null +++ b/src/fuzzy.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { levenshtein, normalize, matchOption, matchYesNo } = require('./fuzzy'); + +const PLANS = ['3-Yr Comprehensive', 'Zero Dep + RSA', 'Engine Protect combo']; + +test('levenshtein basic distances', () => { + assert.equal(levenshtein('kitten', 'kitten'), 0); + assert.equal(levenshtein('kitten', 'sitten'), 1); + assert.equal(levenshtein('', 'abc'), 3); +}); + +test('normalize strips emoji, punctuation and case', () => { + assert.equal(normalize('✅ Yes'), 'yes'); + assert.equal(normalize('Zero Dep + RSA'), 'zero dep rsa'); + assert.equal(normalize('➡️ No, go ahead'), 'no go ahead'); +}); + +test('matchOption resolves an exact button-tap title', () => { + assert.equal(matchOption('3-Yr Comprehensive', PLANS), '3-Yr Comprehensive'); + assert.equal(matchOption('Engine Protect combo', PLANS), 'Engine Protect combo'); +}); + +test('matchOption tolerates typos in the distinctive word', () => { + assert.equal(matchOption('comprehesive', PLANS), '3-Yr Comprehensive'); + assert.equal(matchOption('engin protect', PLANS), 'Engine Protect combo'); +}); + +test('matchOption handles partial / shorthand answers', () => { + assert.equal(matchOption('zero dep rsa', PLANS), 'Zero Dep + RSA'); + assert.equal(matchOption('rsa', PLANS), 'Zero Dep + RSA'); + assert.equal(matchOption('comprehensive', PLANS), '3-Yr Comprehensive'); +}); + +test('matchOption returns null when nothing is close', () => { + assert.equal(matchOption('make it in hindi', PLANS), null); + assert.equal(matchOption('', PLANS), null); +}); + +test('matchYesNo reads yes variants incl. emoji and typos', () => { + for (const s of ['✅ Yes', 'yes', 'yess', 'yeah', 'ya', 'sure', 'haan', 'ok']) { + assert.equal(matchYesNo(s), 'yes', `expected yes for "${s}"`); + } +}); + +test('matchYesNo reads no variants incl. emoji and proceed phrases', () => { + for (const s of ['🙅 No', 'no', 'nope', 'nah', '➡️ No, go ahead', 'go ahead', 'proceed', 'nothing else']) { + assert.equal(matchYesNo(s), 'no', `expected no for "${s}"`); + } +}); + +test('matchYesNo returns null when unrecognised', () => { + assert.equal(matchYesNo('maybe later'), null); + assert.equal(matchYesNo(''), null); +}); diff --git a/src/localFlow.js b/src/localFlow.js index 0feca18308..b687cb8489 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -15,11 +15,16 @@ function loadOfferDesign() { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -// Approved plans + featured governance details, injected into the GPT system -// prompt so it can offer the plan buttons and refuse below-floor price requests. +// Approved plans + featured governance details, plus the fallback customer/model +// and known-model list used by the deterministic Flow-2 trigger (flow2Session.js). function getOfferContext() { const design = loadOfferDesign(); - return { plans: design.plans || [], featured: design.featured || null }; + return { + plans: design.plans || [], + featured: design.featured || null, + defaults: design.defaults || {}, + models: design.models || [], + }; } function sleep(ms) { From dbfe13a4e15bbd12441d5462e173b5bf051064d2 Mon Sep 17 00:00:00 2001 From: varun kalra Date: Mon, 27 Jul 2026 22:00:37 +0530 Subject: [PATCH 38/38] =?UTF-8?q?tune(flow2):=20speed=20up=20generation=20?= =?UTF-8?q?progress=20streaming=20(3500ms=20=E2=86=92=201500ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower the default pause between streamed "generating…" progress messages so the banner arrives faster. Still env-tunable via GEN_STEP_DELAY_MS. --- src/localFlow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/localFlow.js b/src/localFlow.js index b687cb8489..5a9f7d9085 100644 --- a/src/localFlow.js +++ b/src/localFlow.js @@ -36,7 +36,7 @@ function sleep(ms) { // via GEN_STEP_DELAY_MS. async function streamProgress(sendText, phoneNumber, messages) { if (typeof sendText !== 'function') return; - const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 3500); + const delay = Number(process.env.GEN_STEP_DELAY_MS ?? 1500); for (const message of messages) { await sendText(phoneNumber, message); await sleep(delay);