VinOS Agent commited on
Commit
57716c0
Β·
1 Parent(s): b4de7b0

VinOS Auto-Update: Strategic Sync

Browse files
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
- // Text formatting
51
- const isHook = slide.type === 'hook';
52
  const titleFontSize = isHook ? 75 : 60;
53
  const subFontSize = isHook ? 40 : 36;
 
54
 
55
- const maxTitleChars = isHook ? 22 : 28;
56
- const maxSubChars = isHook ? 40 : 45;
57
 
58
  const titleLines = this.wrapText(this.escapeXml(slide.title), maxTitleChars);
59
  const subLines = this.wrapText(this.escapeXml(slide.subtitle), maxSubChars);
60
 
61
- // Generate Y-coordinates to push text down based on line counts
62
- let currentY = 400;
 
 
 
 
63
  let titleTspans = titleLines.map(line => {
64
- const span = `<tspan x="100" y="${currentY}">${line}</tspan>`;
65
- currentY += (titleFontSize * 1.3);
66
  return span;
67
  }).join('\n');
68
 
69
- currentY += (isHook ? 80 : 60); // Gap between title and sub
70
 
71
  let subTspans = subLines.map(line => {
72
- const span = `<tspan x="100" y="${currentY}">${line}</tspan>`;
73
- currentY += (subFontSize * 1.4);
74
  return span;
75
  }).join('\n');
76
 
77
- const slideTypeLabel = this.escapeXml((slide.type || 'slide').toUpperCase());
78
- const footerLabel = 'VinOS Autopilot';
79
- const pageCounter = `${i + 1} / ${slides.length}`;
80
 
81
- // Create Raw SVG structure utilizing pure vector shapes
82
- const svgContent = `
83
- <svg width="1080" height="1350" viewBox="0 0 1080 1350" xmlns="http://www.w3.org/2000/svg">
84
- <!-- Background Layer -->
85
- <rect width="1080" height="1350" fill="#0B0E14" />
 
 
 
 
 
 
 
86
 
87
- <!-- Ambient Glow Orbs -->
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 &amp; 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
  };