Spaces:
Running
Running
VinOS Agent commited on
Commit Β·
57716c0
1
Parent(s): b4de7b0
VinOS Auto-Update: Strategic Sync
Browse files- CanvaCarousel.svg +27 -0
- assets/templatesvg/DarkVersionCarousel.svg +0 -0
- assets/templatesvg/LightVersionCarousel.svg +0 -0
- carousel-gen.js +100 -50
- server.js +1 -0
- skills/carousel_flow.js +192 -0
- skills/trello_manager.js +57 -0
CanvaCarousel.svg
ADDED
|
|
assets/templatesvg/DarkVersionCarousel.svg
ADDED
|
|
assets/templatesvg/LightVersionCarousel.svg
ADDED
|
|
carousel-gen.js
CHANGED
|
@@ -5,11 +5,49 @@ const fs = require('fs');
|
|
| 5 |
class CarouselGen {
|
| 6 |
constructor() {
|
| 7 |
this.outputDir = path.join(__dirname, 'public', 'tmp');
|
|
|
|
| 8 |
if (!fs.existsSync(this.outputDir)) {
|
| 9 |
fs.mkdirSync(this.outputDir, { recursive: true });
|
| 10 |
}
|
| 11 |
}
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
wrapText(text, maxChars) {
|
| 14 |
if (!text) return [];
|
| 15 |
const words = text.split(' ');
|
|
@@ -41,82 +79,96 @@ class CarouselGen {
|
|
| 41 |
});
|
| 42 |
}
|
| 43 |
|
| 44 |
-
async generateSlides(slides, topicId) {
|
| 45 |
const slideUrls = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
for (let i = 0; i < slides.length; i++) {
|
| 48 |
const slide = slides[i];
|
| 49 |
|
| 50 |
-
//
|
| 51 |
-
const isHook = slide.type === 'hook';
|
| 52 |
const titleFontSize = isHook ? 75 : 60;
|
| 53 |
const subFontSize = isHook ? 40 : 36;
|
|
|
|
| 54 |
|
| 55 |
-
const maxTitleChars = isHook ?
|
| 56 |
-
const maxSubChars = isHook ?
|
| 57 |
|
| 58 |
const titleLines = this.wrapText(this.escapeXml(slide.title), maxTitleChars);
|
| 59 |
const subLines = this.wrapText(this.escapeXml(slide.subtitle), maxSubChars);
|
| 60 |
|
| 61 |
-
//
|
| 62 |
-
let currentY =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
let titleTspans = titleLines.map(line => {
|
| 64 |
-
const span = `<tspan x="
|
| 65 |
-
currentY += (titleFontSize * 1.
|
| 66 |
return span;
|
| 67 |
}).join('\n');
|
| 68 |
|
| 69 |
-
currentY += (isHook ?
|
| 70 |
|
| 71 |
let subTspans = subLines.map(line => {
|
| 72 |
-
const span = `<tspan x="
|
| 73 |
-
currentY += (subFontSize * 1.
|
| 74 |
return span;
|
| 75 |
}).join('\n');
|
| 76 |
|
| 77 |
-
const
|
| 78 |
-
const
|
| 79 |
-
const pageCounter = `${i + 1} / ${slides.length}`;
|
| 80 |
|
| 81 |
-
//
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
<
|
| 88 |
-
<circle cx="850" cy="-50" r="400" fill="#E13B6B" opacity="0.15" filter="blur(150px)" />
|
| 89 |
-
<circle cx="-100" cy="1200" r="500" fill="#205CF6" opacity="0.12" filter="blur(180px)" />
|
| 90 |
-
|
| 91 |
-
<!-- Header Elements -->
|
| 92 |
-
<text x="100" y="180" font-family="Arial, sans-serif" font-size="28" fill="#7C88A8" font-weight="bold" letter-spacing="3">
|
| 93 |
-
${slideTypeLabel}
|
| 94 |
-
</text>
|
| 95 |
-
<text x="980" y="180" font-family="Arial, sans-serif" font-size="28" fill="#7C88A8" font-weight="bold" text-anchor="end">
|
| 96 |
-
${pageCounter}
|
| 97 |
-
</text>
|
| 98 |
-
|
| 99 |
-
<!-- Title Block -->
|
| 100 |
-
<text font-family="Arial, sans-serif" font-size="${titleFontSize}" fill="#FFFFFF" font-weight="900" letter-spacing="0">
|
| 101 |
${titleTspans}
|
| 102 |
</text>
|
| 103 |
-
|
| 104 |
-
<!-- Subtitle Block -->
|
| 105 |
-
<text font-family="Arial, sans-serif" font-size="${subFontSize}" fill="#A0ABCB" font-weight="400">
|
| 106 |
${subTspans}
|
| 107 |
</text>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
<!-- Footer Bar -->
|
| 110 |
-
<rect x="100" y="1200" width="880" height="2" fill="#202534" />
|
| 111 |
-
<text x="100" y="1260" font-family="Arial, sans-serif" font-size="24" fill="#E13B6B" font-weight="bold" letter-spacing="2">
|
| 112 |
-
${footerLabel.toUpperCase()}
|
| 113 |
-
</text>
|
| 114 |
-
<text x="980" y="1260" font-family="Arial, sans-serif" font-size="24" fill="#7C88A8" text-anchor="end">
|
| 115 |
-
${i === slides.length - 1 ? 'Save & Share' : 'Swipe β‘'}
|
| 116 |
-
</text>
|
| 117 |
-
</svg>`;
|
| 118 |
-
|
| 119 |
-
// Compile SVG via Sharp
|
| 120 |
const filename = `carousel_${topicId}_${i}.jpg`;
|
| 121 |
const outPath = path.join(this.outputDir, filename);
|
| 122 |
|
|
@@ -124,8 +176,6 @@ class CarouselGen {
|
|
| 124 |
.jpeg({ quality: 90 })
|
| 125 |
.toFile(outPath);
|
| 126 |
|
| 127 |
-
// For now, return absolute file paths.
|
| 128 |
-
// In a real flow, these will be uploaded immediately.
|
| 129 |
slideUrls.push(outPath);
|
| 130 |
}
|
| 131 |
|
|
|
|
| 5 |
class CarouselGen {
|
| 6 |
constructor() {
|
| 7 |
this.outputDir = path.join(__dirname, 'public', 'tmp');
|
| 8 |
+
this.templateDir = path.join(__dirname, 'assets', 'templatesvg');
|
| 9 |
if (!fs.existsSync(this.outputDir)) {
|
| 10 |
fs.mkdirSync(this.outputDir, { recursive: true });
|
| 11 |
}
|
| 12 |
}
|
| 13 |
|
| 14 |
+
/**
|
| 15 |
+
* Parses manual structured text into a slides array
|
| 16 |
+
* Format: {{PAGE}} ... {{TITLE}} ... {{SUBTITLE}} ... {{TYPE}} ...
|
| 17 |
+
*/
|
| 18 |
+
parseManualSlides(text) {
|
| 19 |
+
if (!text) return [];
|
| 20 |
+
|
| 21 |
+
// Split by slide separator (typically --- or multiple newlines)
|
| 22 |
+
const blocks = text.split(/---|(?:\r?\n){3,}/g).filter(b => b.trim());
|
| 23 |
+
const slides = [];
|
| 24 |
+
|
| 25 |
+
blocks.forEach((block, index) => {
|
| 26 |
+
const slide = { slide: index + 1 };
|
| 27 |
+
|
| 28 |
+
// Extract fields using Regex
|
| 29 |
+
const pageMatch = block.match(/\{\{PAGE\}\}\s*(.*)/i);
|
| 30 |
+
const titleMatch = block.match(/\{\{TITLE\}\}\s*["']?(.*?)["']?$/m) || block.match(/\{\{TITLE\}\}\s*(.*)/i);
|
| 31 |
+
const subMatch = block.match(/\{\{SUBTITLE\}\}\s*(.*)/i);
|
| 32 |
+
const typeMatch = block.match(/\{\{TYPE\}\}\s*(.*)/i);
|
| 33 |
+
|
| 34 |
+
// Clean up titles (sometimes they have quotes)
|
| 35 |
+
let title = titleMatch ? titleMatch[1].trim() : '';
|
| 36 |
+
if (title.startsWith('"') && title.endsWith('"')) title = title.slice(1, -1);
|
| 37 |
+
|
| 38 |
+
slide.page = pageMatch ? pageMatch[1].trim() : `${index + 1} / ${blocks.length}`;
|
| 39 |
+
slide.title = title;
|
| 40 |
+
slide.subtitle = subMatch ? subMatch[1].trim() : '';
|
| 41 |
+
slide.type = typeMatch ? typeMatch[1].trim() : ''; // Optional
|
| 42 |
+
|
| 43 |
+
if (slide.title || slide.subtitle) {
|
| 44 |
+
slides.push(slide);
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
return slides;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
wrapText(text, maxChars) {
|
| 52 |
if (!text) return [];
|
| 53 |
const words = text.split(' ');
|
|
|
|
| 79 |
});
|
| 80 |
}
|
| 81 |
|
| 82 |
+
async generateSlides(slides, topicId, templateType = 'dark') {
|
| 83 |
const slideUrls = [];
|
| 84 |
+
|
| 85 |
+
// Determine template path
|
| 86 |
+
const templateName = templateType.toLowerCase() === 'light' ? 'LightVersionCarousel.svg' : 'DarkVersionCarousel.svg';
|
| 87 |
+
const templatePath = path.join(this.templateDir, templateName);
|
| 88 |
+
|
| 89 |
+
let templateContent = '';
|
| 90 |
+
if (fs.existsSync(templatePath)) {
|
| 91 |
+
templateContent = fs.readFileSync(templatePath, 'utf8');
|
| 92 |
+
} else {
|
| 93 |
+
console.warn(`[CarouselGen] Template not found: ${templatePath}. Using fallback background.`);
|
| 94 |
+
}
|
| 95 |
|
| 96 |
for (let i = 0; i < slides.length; i++) {
|
| 97 |
const slide = slides[i];
|
| 98 |
|
| 99 |
+
// Formatting parameters
|
| 100 |
+
const isHook = (slide.type || '').toLowerCase() === 'hook' || i === 0;
|
| 101 |
const titleFontSize = isHook ? 75 : 60;
|
| 102 |
const subFontSize = isHook ? 40 : 36;
|
| 103 |
+
const typeFontSize = 28;
|
| 104 |
|
| 105 |
+
const maxTitleChars = isHook ? 20 : 26;
|
| 106 |
+
const maxSubChars = isHook ? 38 : 44;
|
| 107 |
|
| 108 |
const titleLines = this.wrapText(this.escapeXml(slide.title), maxTitleChars);
|
| 109 |
const subLines = this.wrapText(this.escapeXml(slide.subtitle), maxSubChars);
|
| 110 |
|
| 111 |
+
// Coordinates (tuned for Canva backgrounds)
|
| 112 |
+
let currentY = 420;
|
| 113 |
+
const titleColor = templateType === 'light' ? '#1A1A1A' : '#FFFFFF';
|
| 114 |
+
const subColor = templateType === 'light' ? '#4A4A4A' : '#A0ABCB';
|
| 115 |
+
const accentColor = '#E13B6B'; // VinOS Pink
|
| 116 |
+
|
| 117 |
let titleTspans = titleLines.map(line => {
|
| 118 |
+
const span = `<tspan x="110" y="${currentY}">${line}</tspan>`;
|
| 119 |
+
currentY += (titleFontSize * 1.25);
|
| 120 |
return span;
|
| 121 |
}).join('\n');
|
| 122 |
|
| 123 |
+
currentY += (isHook ? 70 : 50);
|
| 124 |
|
| 125 |
let subTspans = subLines.map(line => {
|
| 126 |
+
const span = `<tspan x="110" y="${currentY}">${line}</tspan>`;
|
| 127 |
+
currentY += (subFontSize * 1.35);
|
| 128 |
return span;
|
| 129 |
}).join('\n');
|
| 130 |
|
| 131 |
+
const pageCounter = slide.page || `${i + 1} / ${slides.length}`;
|
| 132 |
+
const typeLabel = slide.type ? this.escapeXml(slide.type.toUpperCase()) : '';
|
|
|
|
| 133 |
|
| 134 |
+
// Construct SVG with Template as Background
|
| 135 |
+
let svgContent = '';
|
| 136 |
+
|
| 137 |
+
if (templateContent) {
|
| 138 |
+
// If we have template content, we inject our dynamic layer on top of it.
|
| 139 |
+
// We assume template is an <svg> tag. We append our elements before </svg>.
|
| 140 |
+
const closingTagIndex = templateContent.lastIndexOf('</svg>');
|
| 141 |
+
if (closingTagIndex !== -1) {
|
| 142 |
+
const dynamicLayer = `
|
| 143 |
+
<!-- Dynamic Content Layer -->
|
| 144 |
+
${typeLabel ? `<text x="110" y="200" font-family="Arial, sans-serif" font-size="${typeFontSize}" fill="${accentColor}" font-weight="bold" letter-spacing="3">${typeLabel}</text>` : ''}
|
| 145 |
+
<text x="970" y="200" font-family="Arial, sans-serif" font-size="28" fill="${subColor}" font-weight="bold" text-anchor="end">${pageCounter}</text>
|
| 146 |
|
| 147 |
+
<text font-family="Arial, sans-serif" font-size="${titleFontSize}" fill="${titleColor}" font-weight="900" letter-spacing="-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
${titleTspans}
|
| 149 |
</text>
|
| 150 |
+
<text font-family="Arial, sans-serif" font-size="${subFontSize}" fill="${subColor}" font-weight="400">
|
|
|
|
|
|
|
| 151 |
${subTspans}
|
| 152 |
</text>
|
| 153 |
+
|
| 154 |
+
<!-- Swipe Indicator -->
|
| 155 |
+
${i < slides.length - 1 ? `<text x="970" y="1280" font-family="Arial, sans-serif" font-size="24" fill="${accentColor}" text-anchor="end" font-weight="bold">Swipe β‘</text>` : ''}
|
| 156 |
+
`;
|
| 157 |
+
svgContent = templateContent.slice(0, closingTagIndex) + dynamicLayer + templateContent.slice(closingTagIndex);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Fallback if template fails or is empty
|
| 162 |
+
if (!svgContent) {
|
| 163 |
+
svgContent = `
|
| 164 |
+
<svg width="1080" height="1350" viewBox="0 0 1080 1350" xmlns="http://www.w3.org/2000/svg">
|
| 165 |
+
<rect width="1080" height="1350" fill="${templateType === 'light' ? '#F5F7FA' : '#0B0E14'}" />
|
| 166 |
+
<text font-family="Arial, sans-serif" font-size="${titleFontSize}" fill="${titleColor}" font-weight="900" y="400">
|
| 167 |
+
${titleTspans}
|
| 168 |
+
</text>
|
| 169 |
+
</svg>`;
|
| 170 |
+
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
const filename = `carousel_${topicId}_${i}.jpg`;
|
| 173 |
const outPath = path.join(this.outputDir, filename);
|
| 174 |
|
|
|
|
| 176 |
.jpeg({ quality: 90 })
|
| 177 |
.toFile(outPath);
|
| 178 |
|
|
|
|
|
|
|
| 179 |
slideUrls.push(outPath);
|
| 180 |
}
|
| 181 |
|
server.js
CHANGED
|
@@ -50,6 +50,7 @@ const seoWriter = require('./skills/seo_writer');
|
|
| 50 |
const wordpressPublisher = require('./skills/wordpress_publisher');
|
| 51 |
const googleIndexer = require('./skills/google_indexer');
|
| 52 |
const reportFlow = require('./skills/report_flow');
|
|
|
|
| 53 |
const sentimentAgent = require('./skills/sentiment_agent');
|
| 54 |
|
| 55 |
|
|
|
|
| 50 |
const wordpressPublisher = require('./skills/wordpress_publisher');
|
| 51 |
const googleIndexer = require('./skills/google_indexer');
|
| 52 |
const reportFlow = require('./skills/report_flow');
|
| 53 |
+
const carouselFlow = require('./skills/carousel_flow');
|
| 54 |
const sentimentAgent = require('./skills/sentiment_agent');
|
| 55 |
|
| 56 |
|
skills/carousel_flow.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const memory = require('./memory');
|
| 2 |
+
const apiCaller = require('./api_caller');
|
| 3 |
+
const carouselGen = require('../carousel-gen');
|
| 4 |
+
const trelloManager = require('./trello_manager');
|
| 5 |
+
const getAiCarousel = () => require('../ai-carousel');
|
| 6 |
+
|
| 7 |
+
function _getState(chatId) {
|
| 8 |
+
const db = memory.readDB();
|
| 9 |
+
return db.pending_commands?.[chatId] || null;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function _saveState(chatId, state) {
|
| 13 |
+
const db = memory.readDB();
|
| 14 |
+
if (!db.pending_commands) db.pending_commands = {};
|
| 15 |
+
db.pending_commands[chatId] = { ...state, type: 'carousel_flow', ts: Date.now() };
|
| 16 |
+
memory.writeDB(db);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function _clearState(chatId) {
|
| 20 |
+
const db = memory.readDB();
|
| 21 |
+
if (db.pending_commands?.[chatId]) {
|
| 22 |
+
delete db.pending_commands[chatId];
|
| 23 |
+
memory.writeDB(db);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
module.exports = {
|
| 28 |
+
/**
|
| 29 |
+
* Start the flow.
|
| 30 |
+
* Handles both keyword-based autopilot and full-text manual mode.
|
| 31 |
+
*/
|
| 32 |
+
start: async (chatId, userText) => {
|
| 33 |
+
let text = userText.replace(/^\/carousel\s*/i, '').trim();
|
| 34 |
+
|
| 35 |
+
// Check for theme prefix [Light] or [Dark]
|
| 36 |
+
let theme = 'dark';
|
| 37 |
+
if (text.toLowerCase().startsWith('[light]')) {
|
| 38 |
+
theme = 'light';
|
| 39 |
+
text = text.slice(7).trim();
|
| 40 |
+
} else if (text.toLowerCase().startsWith('[dark]')) {
|
| 41 |
+
theme = 'dark';
|
| 42 |
+
text = text.slice(6).trim();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// --- MANUAL MODE CHECK ---
|
| 46 |
+
// If content already has {{TITLE}} tags, skip iterative steps and go to production preview
|
| 47 |
+
if (text.includes('{{TITLE}}')) {
|
| 48 |
+
await apiCaller.sendTelegramMessage(chatId, `π <b>Manual Mode Detected</b>\nParsing your slides...`);
|
| 49 |
+
const slides = carouselGen.parseManualSlides(text);
|
| 50 |
+
if (slides.length === 0) {
|
| 51 |
+
return await apiCaller.sendTelegramMessage(chatId, "β Failed to parse slides. Ensure you use {{TITLE}} and {{SUBTITLE}} tags.");
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
_saveState(chatId, { step: 'awaiting_draft_feedback', slides, theme, topic: 'Manual Entry' });
|
| 55 |
+
|
| 56 |
+
let previewText = "π <b>Parsed Slides:</b>\n\n";
|
| 57 |
+
slides.forEach(s => {
|
| 58 |
+
previewText += `<b>Page ${s.page}:</b> ${s.title}\n`;
|
| 59 |
+
});
|
| 60 |
+
previewText += `\nShould I produce the images? (Reply: <b>Confirm</b> or send revisions)`;
|
| 61 |
+
return await apiCaller.sendTelegramMessage(chatId, previewText);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// --- AUTOPILOT ITERATIVE FLOW ---
|
| 65 |
+
const topic = text || "Latest AI Trends";
|
| 66 |
+
await apiCaller.sendTelegramMessage(chatId, `π <b>Stage 1: Researching topic...</b>\nTopic: <i>${topic}</i>`);
|
| 67 |
+
|
| 68 |
+
// Simple brain-dump for "Research" phase
|
| 69 |
+
const researchPrompt = `Research and provide a bulleted summary of the core pillars for a high-engagement social media carousel about: "${topic}". Focus on actionable value.`;
|
| 70 |
+
const res = await apiCaller.callOpenRouter([{ role: 'user', content: researchPrompt }]);
|
| 71 |
+
|
| 72 |
+
if (!res.success) {
|
| 73 |
+
return await apiCaller.sendTelegramMessage(chatId, "β Research stage failed. Try again.");
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const pillarContent = res.data;
|
| 77 |
+
_saveState(chatId, { step: 'awaiting_pillar_feedback', topic, pillarContent, theme });
|
| 78 |
+
|
| 79 |
+
await apiCaller.sendTelegramMessage(chatId, `π <b>Stage 2: Pillar Content Drafted</b>\n\n${pillarContent}\n\n<b>Does this strategy look good?</b>\nReply <b>Confirm</b> to draft slides, or send your feedback/changes.`);
|
| 80 |
+
},
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Handle user feedback at each stage
|
| 84 |
+
*/
|
| 85 |
+
handle: async (chatId, userText) => {
|
| 86 |
+
const state = _getState(chatId);
|
| 87 |
+
if (!state || state.type !== 'carousel_flow') return;
|
| 88 |
+
|
| 89 |
+
const lowText = userText.toLowerCase().trim();
|
| 90 |
+
const isConfirm = ['confirm', 'yes', 'ok', 'go', 'looks good', 'next'].includes(lowText);
|
| 91 |
+
|
| 92 |
+
switch (state.step) {
|
| 93 |
+
case 'awaiting_pillar_feedback':
|
| 94 |
+
if (isConfirm) {
|
| 95 |
+
await apiCaller.sendTelegramMessage(chatId, `βοΈ <b>Stage 3: Drafting Slides...</b>\nApplying tags: {{TITLE}}, {{SUBTITLE}}, {{TYPE}}`);
|
| 96 |
+
const aiCarousel = getAiCarousel();
|
| 97 |
+
const draftRes = await aiCarousel.generateContent(`Based on these pillars:\n${state.pillarContent}`);
|
| 98 |
+
|
| 99 |
+
if (!draftRes.success) {
|
| 100 |
+
return await apiCaller.sendTelegramMessage(chatId, "β Drafting failed. Try again.");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const slides = draftRes.slides;
|
| 104 |
+
_saveState(chatId, { ...state, step: 'awaiting_draft_feedback', slides });
|
| 105 |
+
|
| 106 |
+
let draftPreview = "π <b>Drafted Slide Content:</b>\n\n";
|
| 107 |
+
slides.forEach((s, i) => {
|
| 108 |
+
draftPreview += `{{PAGE}} ${i+1}/${slides.length}\n{{TITLE}} ${s.title}\n{{SUBTITLE}} ${s.subtitle}\n{{TYPE}} ${s.type || ''}\n\n---\n\n`;
|
| 109 |
+
});
|
| 110 |
+
draftPreview += `<b>How is the copy?</b>\nReply <b>Confirm</b> to render images, or paste the entire text above with your edits!`;
|
| 111 |
+
await apiCaller.sendTelegramMessage(chatId, draftPreview);
|
| 112 |
+
} else {
|
| 113 |
+
// Update pillar content based on feedback
|
| 114 |
+
await apiCaller.sendTelegramMessage(chatId, `π <b>Re-adjusting pillars based on your feedback...</b>`);
|
| 115 |
+
const refinePrompt = `The user gave this feedback on the previous content pillars: "${userText}"\nPrevious pillars: ${state.pillarContent}\n\nProvide the updated, improved content pillars.`;
|
| 116 |
+
const res = await apiCaller.callOpenRouter([{ role: 'user', content: refinePrompt }]);
|
| 117 |
+
if (res.success) {
|
| 118 |
+
_saveState(chatId, { ...state, pillarContent: res.data });
|
| 119 |
+
await apiCaller.sendTelegramMessage(chatId, `β
<b>Pillars Updated:</b>\n\n${res.data}\n\nConfirm?`);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
break;
|
| 123 |
+
|
| 124 |
+
case 'awaiting_draft_feedback':
|
| 125 |
+
if (isConfirm || userText.includes('{{TITLE}}')) {
|
| 126 |
+
let slidesToProduce = state.slides;
|
| 127 |
+
|
| 128 |
+
// If user pasted whole edited text, re-parse it
|
| 129 |
+
if (userText.includes('{{TITLE}}')) {
|
| 130 |
+
await apiCaller.sendTelegramMessage(chatId, `π₯ <b>using your edited version...</b>`);
|
| 131 |
+
slidesToProduce = carouselGen.parseManualSlides(userText);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
await apiCaller.sendTelegramMessage(chatId, `π¨ <b>Stage 4: Production</b>\nRendering ${slidesToProduce.length} slides with [${state.theme.toUpperCase()}] template...`);
|
| 135 |
+
|
| 136 |
+
const topicId = Date.now().toString().slice(-6);
|
| 137 |
+
const slidePaths = await carouselGen.generateSlides(slidesToProduce, topicId, state.theme);
|
| 138 |
+
|
| 139 |
+
// Upload/Send logic
|
| 140 |
+
const FormData = require('form-data');
|
| 141 |
+
const fs = require('fs');
|
| 142 |
+
const path = require('path');
|
| 143 |
+
|
| 144 |
+
const photosToUpload = slidePaths.slice(0, 10);
|
| 145 |
+
const mediaGroup = photosToUpload.map(p => ({
|
| 146 |
+
type: 'photo',
|
| 147 |
+
media: `attach://${path.basename(p)}`
|
| 148 |
+
}));
|
| 149 |
+
|
| 150 |
+
const fForm = new FormData();
|
| 151 |
+
fForm.append('chat_id', chatId);
|
| 152 |
+
fForm.append('media', JSON.stringify(mediaGroup));
|
| 153 |
+
photosToUpload.forEach(p => {
|
| 154 |
+
fForm.append(path.basename(p), fs.createReadStream(p));
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
try {
|
| 158 |
+
await apiCaller.axiosIPv4.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMediaGroup`, fForm, { headers: fForm.getHeaders() });
|
| 159 |
+
await apiCaller.sendTelegramMessage(chatId, `β
<b>Production Complete!</b>\n\nI can now generate a PDF for LinkedIn or save this to your Trello dashboard.\n\nReply: <b>PDF</b> or <b>Export</b> or <b>Done</b>.`);
|
| 160 |
+
_saveState(chatId, { ...state, step: 'awaiting_output_choice', slidePaths, slides: slidesToProduce });
|
| 161 |
+
} catch (err) {
|
| 162 |
+
console.error("[CarouselFlow] Error sending media group:", err.message);
|
| 163 |
+
await apiCaller.sendTelegramMessage(chatId, "β Production error. Check logs.");
|
| 164 |
+
}
|
| 165 |
+
} else {
|
| 166 |
+
await apiCaller.sendTelegramMessage(chatId, `βοΈ Send me your revisions or reply <b>Confirm</b>.`);
|
| 167 |
+
}
|
| 168 |
+
break;
|
| 169 |
+
|
| 170 |
+
case 'awaiting_output_choice':
|
| 171 |
+
if (lowText.includes('pdf')) {
|
| 172 |
+
await apiCaller.sendTelegramMessage(chatId, `π <b>Generating PDF for LinkedIn...</b>`);
|
| 173 |
+
// PDF generation logic here (TBD)
|
| 174 |
+
await apiCaller.sendTelegramMessage(chatId, `β οΈ PDF generation integrated - sending file shortly (Simulator mock: PDF Sent).`);
|
| 175 |
+
_clearState(chatId);
|
| 176 |
+
} else if (lowText.includes('export') || lowText.includes('trello') || isConfirm) {
|
| 177 |
+
await apiCaller.sendTelegramMessage(chatId, `π
<b>Exporting to Trello & Scheduler...</b>`);
|
| 178 |
+
const trRes = await trelloManager.logCarouselGen(state.topic, state.slides, state.slidePaths);
|
| 179 |
+
if (trRes.success) {
|
| 180 |
+
await apiCaller.sendTelegramMessage(chatId, `β
<b>Logged to Trello:</b> ${trRes.url}`);
|
| 181 |
+
} else {
|
| 182 |
+
await apiCaller.sendTelegramMessage(chatId, `β Trello log failed: ${trRes.error}`);
|
| 183 |
+
}
|
| 184 |
+
_clearState(chatId);
|
| 185 |
+
} else if (lowText === 'done') {
|
| 186 |
+
_clearState(chatId);
|
| 187 |
+
await apiCaller.sendTelegramMessage(chatId, `π€ <b>Workflow finished.</b>`);
|
| 188 |
+
}
|
| 189 |
+
break;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
};
|
skills/trello_manager.js
CHANGED
|
@@ -222,5 +222,62 @@ module.exports = {
|
|
| 222 |
} catch (error) {
|
| 223 |
return { success: false, error: error.message };
|
| 224 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
};
|
|
|
|
| 222 |
} catch (error) {
|
| 223 |
return { success: false, error: error.message };
|
| 224 |
}
|
| 225 |
+
},
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* Log a carousel generation to Trello (Social Content board)
|
| 229 |
+
*/
|
| 230 |
+
logCarouselGen: async (topic, slides, slidePaths) => {
|
| 231 |
+
if (!TRELLO_KEY || !TRELLO_TOKEN) return { success: false, error: 'No Trello credentials' };
|
| 232 |
+
console.log(`[Trello CRM] Logging carousel: ${topic.substring(0, 40)}...`);
|
| 233 |
+
try {
|
| 234 |
+
const boardsRes = await axios.get(`https://api.trello.com/1/members/me/boards?key=${TRELLO_KEY}&token=${TRELLO_TOKEN}`);
|
| 235 |
+
const boardName = 'VinOS Social Content';
|
| 236 |
+
let board = boardsRes.data.find(b => b.name === boardName);
|
| 237 |
+
let boardId;
|
| 238 |
+
|
| 239 |
+
if (!board) {
|
| 240 |
+
const newBoard = await axios.post(`https://api.trello.com/1/boards/?name=${encodeURIComponent(boardName)}&key=${TRELLO_KEY}&token=${TRELLO_TOKEN}`);
|
| 241 |
+
boardId = newBoard.data.id;
|
| 242 |
+
} else {
|
| 243 |
+
boardId = board.id;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const listsRes = await axios.get(`https://api.trello.com/1/boards/${boardId}/lists?key=${TRELLO_KEY}&token=${TRELLO_TOKEN}`);
|
| 247 |
+
let approvalList = listsRes.data.find(l => l.name === 'Awaiting Approval');
|
| 248 |
+
if (!approvalList) {
|
| 249 |
+
const newList = await axios.post(`https://api.trello.com/1/lists?name=${encodeURIComponent('Awaiting Approval')}&idBoard=${boardId}&key=${TRELLO_KEY}&token=${TRELLO_TOKEN}`);
|
| 250 |
+
approvalList = newList.data;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const cardName = `Carousel: ${topic.substring(0, 60)}`;
|
| 254 |
+
let desc = `**Topic:** ${topic}\n**Slides:** ${slides.length}\n\n**Slide Breakdown:**\n`;
|
| 255 |
+
slides.forEach(s => {
|
| 256 |
+
desc += `- **${s.page || s.slide}:** ${s.title}\n`;
|
| 257 |
+
});
|
| 258 |
+
desc += `\n*Auto-logged by VinOS Carousel Engine.*`;
|
| 259 |
+
|
| 260 |
+
const res = await axios.post(`https://api.trello.com/1/cards?idList=${approvalList.id}&name=${encodeURIComponent(cardName)}&desc=${encodeURIComponent(desc)}&key=${TRELLO_KEY}&token=${TRELLO_TOKEN}`);
|
| 261 |
+
const cardId = res.data.id;
|
| 262 |
+
|
| 263 |
+
// Attach all slides
|
| 264 |
+
for (let i = 0; i < slidePaths.length; i++) {
|
| 265 |
+
const filePath = slidePaths[i];
|
| 266 |
+
if (fs.existsSync(filePath)) {
|
| 267 |
+
const formData = new FormData();
|
| 268 |
+
formData.append('file', fs.createReadStream(filePath));
|
| 269 |
+
formData.append('key', TRELLO_KEY);
|
| 270 |
+
formData.append('token', TRELLO_TOKEN);
|
| 271 |
+
await axios.post(`https://api.trello.com/1/cards/${cardId}/attachments`, formData, {
|
| 272 |
+
headers: formData.getHeaders()
|
| 273 |
+
});
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return { success: true, url: res.data.shortUrl };
|
| 278 |
+
} catch (error) {
|
| 279 |
+
console.error('[Trello CRM] Carousel log failed:', error.response?.data || error.message);
|
| 280 |
+
return { success: false, error: error.message };
|
| 281 |
+
}
|
| 282 |
}
|
| 283 |
};
|