squidclaw 2.1.0 โ†’ 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,6 +202,18 @@ export class TelegramManager {
202
202
  /**
203
203
  * Send voice note
204
204
  */
205
+ async sendDocument(agentId, contactId, filePath, fileName, caption, metadata = {}) {
206
+ const botInfo = this.bots.get(agentId);
207
+ if (!botInfo?.bot) throw new Error('Bot not running');
208
+ const chatId = contactId.replace('tg_', '');
209
+ const { readFileSync } = await import('fs');
210
+ const buffer = readFileSync(filePath);
211
+ await botInfo.bot.api.sendDocument(chatId, new InputFile(buffer, fileName || 'file'), {
212
+ caption: caption || '',
213
+ });
214
+ logger.info('telegram', 'Sent document: ' + fileName);
215
+ }
216
+
205
217
  async sendVoiceNote(agentId, contactId, audioBuffer, metadata = {}) {
206
218
  const botInfo = this.bots.get(agentId);
207
219
  if (!botInfo?.bot) return;
@@ -58,6 +58,8 @@ export function addToolSupport(agent, toolRouter, knowledgeBase) {
58
58
  const fullResponse = result.messages.join('\n');
59
59
  toolRouter._currentContactId = contactId;
60
60
  toolRouter.storage = agent.storage;
61
+ toolRouter._currentContactId = contactId;
62
+ toolRouter._currentPlatform = metadata?.platform;
61
63
  const toolResult = await toolRouter.processResponse(fullResponse, agent.id);
62
64
 
63
65
  if (toolResult.toolUsed && toolResult.toolName === 'remind' && toolResult.reminderTime) {
@@ -71,6 +73,14 @@ export function addToolSupport(agent, toolRouter, knowledgeBase) {
71
73
  return result;
72
74
  }
73
75
 
76
+ // File attachment (pptx, etc)
77
+ if (toolResult.toolUsed && toolResult.filePath) {
78
+ result.filePath = toolResult.filePath;
79
+ result.fileName = toolResult.fileName;
80
+ result.messages = [toolResult.toolResult || 'Here\'s your file! ๐Ÿ“Ž'];
81
+ return result;
82
+ }
83
+
74
84
  if (toolResult.toolUsed && (toolResult.imageBase64 || toolResult.imageUrl)) {
75
85
  // Image generated โ€” pass through directly
76
86
  result.image = { base64: toolResult.imageBase64, url: toolResult.imageUrl, mimeType: toolResult.mimeType };
@@ -12,6 +12,8 @@ export async function aiProcessMiddleware(ctx, next) {
12
12
  messages: result.messages || [],
13
13
  reaction: result.reaction || null,
14
14
  image: result.image || null,
15
+ filePath: result.filePath || null,
16
+ fileName: result.fileName || null,
15
17
  _reminder: result._reminder || null,
16
18
  };
17
19
  ctx.metadata.originalType = ctx.metadata.originalType || null;
@@ -39,27 +39,32 @@ export async function commandsMiddleware(ctx, next) {
39
39
 
40
40
  if (cmd === '/status' || textCmd === 'status') {
41
41
  const uptime = process.uptime();
42
- const h = Math.floor(uptime / 3600);
42
+ const d = Math.floor(uptime / 86400);
43
+ const h = Math.floor((uptime % 86400) / 3600);
43
44
  const m = Math.floor((uptime % 3600) / 60);
45
+ const uptimeStr = d > 0 ? d + 'd ' + h + 'h' : h + 'h ' + m + 'm';
44
46
  const usage = await ctx.storage.getUsage(ctx.agentId) || {};
45
47
  const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
46
- const fmtT = tokens >= 1e6 ? (tokens/1e6).toFixed(1)+'M' : tokens >= 1e3 ? (tokens/1e3).toFixed(1)+'K' : String(tokens);
48
+ const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n || 0);
47
49
  const waOn = Object.values(ctx.engine.whatsappManager?.getStatuses() || {}).some(s => s.connected);
50
+ const memCount = ctx.storage.db.prepare('SELECT COUNT(*) as c FROM memories WHERE agent_id = ?').get(ctx.agentId)?.c || 0;
51
+ const convCount = ctx.storage.db.prepare('SELECT COUNT(*) as c FROM conversations WHERE agent_id = ?').get(ctx.agentId)?.c || 0;
52
+ const reminderCount = ctx.storage.db.prepare("SELECT COUNT(*) as c FROM reminders WHERE agent_id = ? AND fired = 0").get(ctx.agentId)?.c || 0;
53
+ const ram = (process.memoryUsage().rss / 1024 / 1024).toFixed(0);
48
54
 
49
55
  await ctx.reply([
50
- `๐Ÿฆ‘ *${ctx.agent?.name || ctx.agentId} Status*`,
51
- 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€',
52
- `๐Ÿง  Model: ${ctx.agent?.model || 'unknown'}`,
53
- `โฑ๏ธ Uptime: ${h}h ${m}m`,
54
- `๐Ÿ’ฌ Messages: ${usage.messages || 0}`,
55
- `๐Ÿช™ Tokens: ${fmtT}`,
56
- `๐Ÿ’ฐ Cost: $${(usage.cost_usd || 0).toFixed(4)}`,
57
- 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€',
58
- `๐Ÿ“ฑ WhatsApp: ${waOn ? 'โœ…' : 'โŒ'}`,
59
- `โœˆ๏ธ Telegram: ${ctx.engine.telegramManager ? 'โœ…' : 'โŒ'}`,
60
- 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€',
61
- `โšก Skills: 20+`,
62
- `๐Ÿ—ฃ๏ธ Language: ${ctx.agent?.language || 'bilingual'}`,
56
+ '๐Ÿฆ‘ *Squidclaw v2.2.0*',
57
+ '*' + (ctx.agent?.name || 'Agent') + '* โ€” Status',
58
+ '๐Ÿง  Model: ' + (ctx.agent?.model || '?'),
59
+ 'โšก Pipeline: 14 middleware ยท 40+ skills',
60
+ '๐Ÿ—ฃ๏ธ Language: Bilingual (AR/EN)',
61
+ '๐Ÿ’ฌ Messages: ' + convCount,
62
+ '๐Ÿช™ Tokens: ' + fmtT(tokens) + ' (โ†‘' + fmtT(usage.input_tokens || 0) + ' โ†“' + fmtT(usage.output_tokens || 0) + ')',
63
+ '๐Ÿ’ฐ Cost: $' + (usage.cost_usd || 0).toFixed(4),
64
+ 'โœˆ๏ธ Telegram: ' + (ctx.engine.telegramManager ? '๐ŸŸข' : '๐Ÿ”ด') + ' ยท WhatsApp: ' + (waOn ? '๐ŸŸข' : '๐Ÿ”ด'),
65
+ '๐Ÿ’พ Memories: ' + memCount + ' ยท Reminders: ' + reminderCount,
66
+ 'โฑ๏ธ Uptime: ' + uptimeStr + ' ยท RAM: ' + ram + 'MB',
67
+ '๐Ÿ’š Heartbeat: active',
63
68
  ].join('\n'));
64
69
  return;
65
70
  }
@@ -132,6 +137,14 @@ export async function commandsMiddleware(ctx, next) {
132
137
  return;
133
138
  }
134
139
 
140
+ if (cmd === '/reset' || cmd === '/restart') {
141
+ await ctx.reply('๐Ÿ”„ Restarting Squidclaw...');
142
+ setTimeout(() => {
143
+ process.exit(0); // Process manager (pm2/systemd) will restart it
144
+ }, 1000);
145
+ return;
146
+ }
147
+
135
148
  if (cmd === '/allow') {
136
149
  const args = msg.slice(7).trim();
137
150
  if (!args) { await ctx.reply('Usage: /allow <user_id or phone>'); return; }
@@ -29,6 +29,18 @@ export async function responseSenderMiddleware(ctx, next) {
29
29
 
30
30
  // Send via appropriate channel
31
31
  if (ctx.platform === 'telegram' && tm) {
32
+ // Send file attachment (pptx, etc)
33
+ if (response.filePath) {
34
+ try {
35
+ await tm.sendDocument(agentId, contactId, response.filePath, response.fileName, response.messages?.[0] || '');
36
+ await next();
37
+ return;
38
+ } catch (err) {
39
+ logger.error('sender', 'File send failed: ' + err.message);
40
+ // Fall through to text
41
+ }
42
+ }
43
+
32
44
  if (response.image) {
33
45
  const photoData = response.image.url ? { url: response.image.url } : { base64: response.image.base64 };
34
46
  await tm.sendPhoto(agentId, contactId, photoData, response.messages?.[0] || '', metadata);
@@ -0,0 +1,744 @@
1
+ /**
2
+ * ๐Ÿฆ‘ PowerPoint Generator PRO
3
+ * Full-featured .pptx with charts, images, tables, layouts
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+ import { writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ export class PptxGenerator {
11
+ constructor() {
12
+ this.outputDir = '/tmp/squidclaw-pptx';
13
+ mkdirSync(this.outputDir, { recursive: true });
14
+ }
15
+
16
+ // โ”€โ”€ THEMES โ”€โ”€
17
+ static THEMES = {
18
+ dark: { bg: '0d1117', bg2: '161b22', title: 'e6edf3', text: 'b1bac4', accent: '58a6ff', accent2: '3fb950', accent3: 'bc8cff', border: '30363d' },
19
+ light: { bg: 'ffffff', bg2: 'f6f8fa', title: '1f2328', text: '656d76', accent: '0969da', accent2: '1a7f37', accent3: '8250df', border: 'd0d7de' },
20
+ blue: { bg: '0a1628', bg2: '0f2440', title: 'ffffff', text: 'a3c4e8', accent: '4da6ff', accent2: '00d4aa', accent3: 'ff6b9d', border: '1e3a5f' },
21
+ green: { bg: '0d1f0d', bg2: '132a13', title: 'ffffff', text: 'a7d7a7', accent: '4ade80', accent2: 'facc15', accent3: 'fb923c', border: '1a3d1a' },
22
+ corporate: { bg: 'ffffff', bg2: 'f1f5f9', title: '0f172a', text: '475569', accent: '2563eb', accent2: '059669', accent3: 'dc2626', border: 'e2e8f0' },
23
+ red: { bg: '1a0000', bg2: '2d0a0a', title: 'ffffff', text: 'fca5a5', accent: 'ef4444', accent2: 'f59e0b', accent3: '8b5cf6', border: '450a0a' },
24
+ gradient: { bg: '0c0c1d', bg2: '1a1a3e', title: 'ffffff', text: 'c4b5fd', accent: '818cf8', accent2: 'f472b6', accent3: '34d399', border: '312e81' },
25
+ minimal: { bg: 'fafafa', bg2: 'f0f0f0', title: '111111', text: '555555', accent: '111111', accent2: '888888', accent3: 'bbbbbb', border: 'dddddd' },
26
+ saudi: { bg: '003c1f', bg2: '004d28', title: 'ffffff', text: 'c8e6c9', accent: '4caf50', accent2: 'ffd54f', accent3: 'ffffff', border: '1b5e20' },
27
+ ocean: { bg: '0a192f', bg2: '112240', title: 'ccd6f6', text: '8892b0', accent: '64ffda', accent2: 'ffd700', accent3: 'ff6b6b', border: '233554' },
28
+ };
29
+
30
+ /**
31
+ * Create a full presentation
32
+ */
33
+ async create(title, slides, options = {}) {
34
+ const pptxgen = (await import('pptxgenjs')).default;
35
+ const pres = new pptxgen();
36
+
37
+ pres.author = options.author || 'Squidclaw AI ๐Ÿฆ‘';
38
+ pres.title = title;
39
+ pres.subject = options.subtitle || title;
40
+ pres.layout = 'LAYOUT_WIDE'; // 16:9
41
+
42
+ const theme = PptxGenerator.THEMES[options.theme] || PptxGenerator.THEMES.corporate;
43
+
44
+ // โ”€โ”€ TITLE SLIDE โ”€โ”€
45
+ this._addTitleSlide(pres, title, options, theme);
46
+
47
+ // โ”€โ”€ CONTENT SLIDES โ”€โ”€
48
+ for (let i = 0; i < slides.length; i++) {
49
+ const slide = slides[i];
50
+ const slideNum = i + 2;
51
+ const totalSlides = slides.length + 1;
52
+
53
+ switch (slide.type) {
54
+ case 'chart':
55
+ this._addChartSlide(pres, slide, theme, slideNum, totalSlides);
56
+ break;
57
+ case 'table':
58
+ this._addTableSlide(pres, slide, theme, slideNum, totalSlides);
59
+ break;
60
+ case 'comparison':
61
+ this._addComparisonSlide(pres, slide, theme, slideNum, totalSlides);
62
+ break;
63
+ case 'timeline':
64
+ this._addTimelineSlide(pres, slide, theme, slideNum, totalSlides);
65
+ break;
66
+ case 'stats':
67
+ this._addStatsSlide(pres, slide, theme, slideNum, totalSlides);
68
+ break;
69
+ case 'quote':
70
+ this._addQuoteSlide(pres, slide, theme, slideNum, totalSlides);
71
+ break;
72
+ case 'image':
73
+ this._addImageSlide(pres, slide, theme, slideNum, totalSlides);
74
+ break;
75
+ case 'section':
76
+ this._addSectionSlide(pres, slide, theme, slideNum, totalSlides);
77
+ break;
78
+ default:
79
+ this._addContentSlide(pres, slide, theme, slideNum, totalSlides);
80
+ }
81
+ }
82
+
83
+ // โ”€โ”€ THANK YOU SLIDE โ”€โ”€
84
+ if (options.thankYou !== false) {
85
+ this._addThankYouSlide(pres, options, theme);
86
+ }
87
+
88
+ // Save
89
+ const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
90
+ const filepath = join(this.outputDir, filename);
91
+ const data = await pres.write({ outputType: 'nodebuffer' });
92
+ writeFileSync(filepath, data);
93
+
94
+ logger.info('pptx', `Created PRO: ${filepath} (${slides.length + 2} slides)`);
95
+ return { filepath, filename, slideCount: slides.length + 2 };
96
+ }
97
+
98
+ // โ”€โ”€ SLIDE BUILDERS โ”€โ”€
99
+
100
+ _addBase(pres, theme, slideNum, totalSlides) {
101
+ const s = pres.addSlide();
102
+ s.background = { color: theme.bg };
103
+
104
+ // Top accent bar
105
+ s.addShape(pres.ShapeType.rect, {
106
+ x: 0, y: 0, w: '100%', h: 0.06, fill: { color: theme.accent },
107
+ });
108
+
109
+ // Footer
110
+ if (slideNum && totalSlides) {
111
+ s.addText(slideNum + ' / ' + totalSlides, {
112
+ x: 11.5, y: 7.1, w: 1.5, h: 0.3,
113
+ fontSize: 9, color: theme.text, align: 'right', italic: true,
114
+ });
115
+ s.addText('Squidclaw AI ๐Ÿฆ‘', {
116
+ x: 0.3, y: 7.1, w: 2, h: 0.3,
117
+ fontSize: 9, color: theme.border,
118
+ });
119
+ }
120
+ return s;
121
+ }
122
+
123
+ _addTitleSlide(pres, title, options, theme) {
124
+ const s = pres.addSlide();
125
+ s.background = { color: theme.bg };
126
+
127
+ // Large accent shape
128
+ s.addShape(pres.ShapeType.rect, {
129
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
130
+ });
131
+ s.addShape(pres.ShapeType.rect, {
132
+ x: 0.5, y: 5.5, w: 3, h: 0.05, fill: { color: theme.accent },
133
+ });
134
+
135
+ // Title
136
+ s.addText(title, {
137
+ x: 0.5, y: 1.8, w: 12, h: 1.5,
138
+ fontSize: 42, bold: true, color: theme.title,
139
+ fontFace: 'Arial',
140
+ });
141
+
142
+ // Subtitle
143
+ if (options.subtitle) {
144
+ s.addText(options.subtitle, {
145
+ x: 0.5, y: 3.4, w: 10, h: 0.8,
146
+ fontSize: 20, color: theme.text, fontFace: 'Arial',
147
+ });
148
+ }
149
+
150
+ // Date + author
151
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
152
+ s.addText((options.author || '') + (options.author ? ' โ€ข ' : '') + date, {
153
+ x: 0.5, y: 5.8, w: 8, h: 0.5,
154
+ fontSize: 14, color: theme.accent, fontFace: 'Arial',
155
+ });
156
+ }
157
+
158
+ _addContentSlide(pres, slide, theme, slideNum, total) {
159
+ const s = this._addBase(pres, theme, slideNum, total);
160
+
161
+ // Title
162
+ s.addText(slide.title || '', {
163
+ x: 0.5, y: 0.3, w: 12, h: 0.8,
164
+ fontSize: 28, bold: true, color: theme.title, fontFace: 'Arial',
165
+ });
166
+
167
+ // Accent line under title
168
+ s.addShape(pres.ShapeType.rect, {
169
+ x: 0.5, y: 1.15, w: 2.5, h: 0.04, fill: { color: theme.accent },
170
+ });
171
+
172
+ if (slide.bullets && slide.bullets.length > 0) {
173
+ const rows = slide.bullets.map(b => ({
174
+ text: b,
175
+ options: {
176
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
177
+ bullet: { type: 'bullet', style: 'โ—', color: theme.accent },
178
+ paraSpaceAfter: 10, paraSpaceBefore: 4,
179
+ indentLevel: 0,
180
+ },
181
+ }));
182
+
183
+ s.addText(rows, { x: 0.7, y: 1.5, w: 11.5, h: 5 });
184
+ }
185
+
186
+ if (slide.content) {
187
+ s.addText(slide.content, {
188
+ x: 0.5, y: 1.5, w: 12, h: 5,
189
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
190
+ valign: 'top', wrap: true, lineSpacing: 28,
191
+ });
192
+ }
193
+ }
194
+
195
+ _addChartSlide(pres, slide, theme, slideNum, total) {
196
+ const s = this._addBase(pres, theme, slideNum, total);
197
+
198
+ s.addText(slide.title || 'Chart', {
199
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
200
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
201
+ });
202
+ s.addShape(pres.ShapeType.rect, {
203
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
204
+ });
205
+
206
+ const chartType = slide.chartType || 'bar';
207
+ const chartTypes = {
208
+ bar: pres.ChartType.bar,
209
+ line: pres.ChartType.line,
210
+ pie: pres.ChartType.pie,
211
+ doughnut: pres.ChartType.doughnut,
212
+ area: pres.ChartType.area,
213
+ bar3d: pres.ChartType.bar3D,
214
+ };
215
+
216
+ const type = chartTypes[chartType] || pres.ChartType.bar;
217
+ const colors = [theme.accent, theme.accent2, theme.accent3, theme.text, theme.border];
218
+
219
+ const chartData = slide.chartData || [
220
+ { name: 'Series 1', labels: ['A', 'B', 'C', 'D'], values: [10, 20, 30, 40] }
221
+ ];
222
+
223
+ s.addChart(type, chartData, {
224
+ x: 0.8, y: 1.4, w: 11.5, h: 5.2,
225
+ showTitle: false,
226
+ showValue: slide.showValues !== false,
227
+ catAxisLabelColor: theme.text,
228
+ valAxisLabelColor: theme.text,
229
+ chartColors: colors,
230
+ legendColor: theme.text,
231
+ showLegend: chartData.length > 1,
232
+ legendPos: 'b',
233
+ dataLabelColor: theme.title,
234
+ dataLabelFontSize: 10,
235
+ valGridLine: { color: theme.border, size: 0.5 },
236
+ });
237
+ }
238
+
239
+ _addTableSlide(pres, slide, theme, slideNum, total) {
240
+ const s = this._addBase(pres, theme, slideNum, total);
241
+
242
+ s.addText(slide.title || 'Table', {
243
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
244
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
245
+ });
246
+ s.addShape(pres.ShapeType.rect, {
247
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
248
+ });
249
+
250
+ const rows = slide.tableData || [['Header 1', 'Header 2'], ['Data 1', 'Data 2']];
251
+ const tableRows = rows.map((row, ri) =>
252
+ row.map(cell => ({
253
+ text: String(cell),
254
+ options: {
255
+ fontSize: ri === 0 ? 14 : 13,
256
+ bold: ri === 0,
257
+ color: ri === 0 ? 'ffffff' : theme.text,
258
+ fill: { color: ri === 0 ? theme.accent : (ri % 2 === 0 ? theme.bg2 : theme.bg) },
259
+ border: [{ color: theme.border, pt: 0.5 }],
260
+ align: 'left',
261
+ valign: 'middle',
262
+ fontFace: 'Arial',
263
+ },
264
+ }))
265
+ );
266
+
267
+ const colW = 11.5 / (rows[0]?.length || 2);
268
+ s.addTable(tableRows, {
269
+ x: 0.5, y: 1.4, w: 12, h: 0.5,
270
+ colW: Array(rows[0]?.length || 2).fill(colW),
271
+ rowH: rows.map((_, i) => i === 0 ? 0.5 : 0.45),
272
+ margin: [5, 8, 5, 8],
273
+ });
274
+ }
275
+
276
+ _addComparisonSlide(pres, slide, theme, slideNum, total) {
277
+ const s = this._addBase(pres, theme, slideNum, total);
278
+
279
+ s.addText(slide.title || 'Comparison', {
280
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
281
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
282
+ });
283
+ s.addShape(pres.ShapeType.rect, {
284
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
285
+ });
286
+
287
+ const left = slide.left || { title: 'Option A', points: ['Point 1'] };
288
+ const right = slide.right || { title: 'Option B', points: ['Point 1'] };
289
+
290
+ // Left column
291
+ s.addShape(pres.ShapeType.roundRect, {
292
+ x: 0.5, y: 1.4, w: 5.8, h: 5.2,
293
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
294
+ line: { color: theme.accent, width: 1.5 },
295
+ });
296
+ s.addText(left.title, {
297
+ x: 0.8, y: 1.6, w: 5.2, h: 0.6,
298
+ fontSize: 20, bold: true, color: theme.accent, fontFace: 'Arial',
299
+ });
300
+ if (left.points) {
301
+ const lpts = left.points.map(p => ({
302
+ text: p,
303
+ options: { fontSize: 15, color: theme.text, bullet: { code: '2022', color: theme.accent }, paraSpaceAfter: 8 },
304
+ }));
305
+ s.addText(lpts, { x: 1, y: 2.3, w: 4.8, h: 4 });
306
+ }
307
+
308
+ // VS
309
+ s.addText('VS', {
310
+ x: 5.8, y: 3.5, w: 1.4, h: 0.6,
311
+ fontSize: 18, bold: true, color: theme.accent, align: 'center',
312
+ });
313
+
314
+ // Right column
315
+ s.addShape(pres.ShapeType.roundRect, {
316
+ x: 6.7, y: 1.4, w: 5.8, h: 5.2,
317
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
318
+ line: { color: theme.accent2, width: 1.5 },
319
+ });
320
+ s.addText(right.title, {
321
+ x: 7, y: 1.6, w: 5.2, h: 0.6,
322
+ fontSize: 20, bold: true, color: theme.accent2, fontFace: 'Arial',
323
+ });
324
+ if (right.points) {
325
+ const rpts = right.points.map(p => ({
326
+ text: p,
327
+ options: { fontSize: 15, color: theme.text, bullet: { code: '2022', color: theme.accent2 }, paraSpaceAfter: 8 },
328
+ }));
329
+ s.addText(rpts, { x: 7.2, y: 2.3, w: 4.8, h: 4 });
330
+ }
331
+ }
332
+
333
+ _addTimelineSlide(pres, slide, theme, slideNum, total) {
334
+ const s = this._addBase(pres, theme, slideNum, total);
335
+
336
+ s.addText(slide.title || 'Timeline', {
337
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
338
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
339
+ });
340
+ s.addShape(pres.ShapeType.rect, {
341
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
342
+ });
343
+
344
+ const events = slide.events || [{ date: '2024', text: 'Event 1' }];
345
+ const count = Math.min(events.length, 6);
346
+ const totalW = 11.5;
347
+ const spacing = totalW / count;
348
+
349
+ // Timeline line
350
+ s.addShape(pres.ShapeType.rect, {
351
+ x: 0.5, y: 3.5, w: totalW, h: 0.04, fill: { color: theme.accent },
352
+ });
353
+
354
+ events.slice(0, 6).forEach((ev, i) => {
355
+ const x = 0.5 + (i * spacing) + (spacing / 2) - 0.8;
356
+ const above = i % 2 === 0;
357
+
358
+ // Circle
359
+ s.addShape(pres.ShapeType.ellipse, {
360
+ x: x + 0.55, y: 3.3, w: 0.4, h: 0.4,
361
+ fill: { color: theme.accent },
362
+ });
363
+
364
+ // Date
365
+ s.addText(ev.date || '', {
366
+ x: x, y: above ? 2.2 : 3.9, w: 1.6, h: 0.4,
367
+ fontSize: 13, bold: true, color: theme.accent, align: 'center', fontFace: 'Arial',
368
+ });
369
+
370
+ // Text
371
+ s.addText(ev.text || '', {
372
+ x: x - 0.2, y: above ? 2.6 : 4.3, w: 2, h: 0.8,
373
+ fontSize: 11, color: theme.text, align: 'center', fontFace: 'Arial', wrap: true,
374
+ });
375
+ });
376
+ }
377
+
378
+ _addStatsSlide(pres, slide, theme, slideNum, total) {
379
+ const s = this._addBase(pres, theme, slideNum, total);
380
+
381
+ s.addText(slide.title || 'Key Metrics', {
382
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
383
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
384
+ });
385
+ s.addShape(pres.ShapeType.rect, {
386
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
387
+ });
388
+
389
+ const stats = slide.stats || [{ value: '100', label: 'Stat' }];
390
+ const count = Math.min(stats.length, 4);
391
+ const cardW = 2.5;
392
+ const gap = (12 - (count * cardW)) / (count + 1);
393
+ const colors = [theme.accent, theme.accent2, theme.accent3, theme.text];
394
+
395
+ stats.slice(0, 4).forEach((stat, i) => {
396
+ const x = gap + i * (cardW + gap);
397
+
398
+ // Card bg
399
+ s.addShape(pres.ShapeType.roundRect, {
400
+ x, y: 2, w: cardW, h: 3.5,
401
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
402
+ line: { color: colors[i], width: 1.5 },
403
+ });
404
+
405
+ // Icon/emoji
406
+ if (stat.icon) {
407
+ s.addText(stat.icon, {
408
+ x, y: 2.3, w: cardW, h: 0.8,
409
+ fontSize: 32, align: 'center',
410
+ });
411
+ }
412
+
413
+ // Big number
414
+ s.addText(stat.value || '0', {
415
+ x, y: stat.icon ? 3 : 2.5, w: cardW, h: 1.2,
416
+ fontSize: 40, bold: true, color: colors[i], align: 'center', fontFace: 'Arial',
417
+ });
418
+
419
+ // Label
420
+ s.addText(stat.label || '', {
421
+ x, y: stat.icon ? 4.1 : 3.7, w: cardW, h: 0.8,
422
+ fontSize: 14, color: theme.text, align: 'center', fontFace: 'Arial', wrap: true,
423
+ });
424
+
425
+ // Sub text
426
+ if (stat.sub) {
427
+ s.addText(stat.sub, {
428
+ x, y: stat.icon ? 4.7 : 4.3, w: cardW, h: 0.5,
429
+ fontSize: 11, color: theme.border, align: 'center', fontFace: 'Arial', italic: true,
430
+ });
431
+ }
432
+ });
433
+ }
434
+
435
+ _addQuoteSlide(pres, slide, theme, slideNum, total) {
436
+ const s = this._addBase(pres, theme, slideNum, total);
437
+
438
+ // Large quote mark
439
+ s.addText('"', {
440
+ x: 1, y: 1.2, w: 2, h: 2,
441
+ fontSize: 120, color: theme.accent, fontFace: 'Georgia', bold: true,
442
+ });
443
+
444
+ // Quote text
445
+ s.addText(slide.quote || slide.content || '', {
446
+ x: 1.5, y: 2.5, w: 10, h: 2.5,
447
+ fontSize: 28, color: theme.title, fontFace: 'Georgia', italic: true,
448
+ lineSpacing: 36, wrap: true,
449
+ });
450
+
451
+ // Attribution
452
+ if (slide.author || slide.attribution) {
453
+ s.addShape(pres.ShapeType.rect, {
454
+ x: 1.5, y: 5.2, w: 1.5, h: 0.03, fill: { color: theme.accent },
455
+ });
456
+ s.addText('โ€” ' + (slide.author || slide.attribution), {
457
+ x: 1.5, y: 5.4, w: 8, h: 0.5,
458
+ fontSize: 16, color: theme.accent, fontFace: 'Arial',
459
+ });
460
+ }
461
+ }
462
+
463
+ _addImageSlide(pres, slide, theme, slideNum, total) {
464
+ const s = this._addBase(pres, theme, slideNum, total);
465
+
466
+ if (slide.title) {
467
+ s.addText(slide.title, {
468
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
469
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
470
+ });
471
+ }
472
+
473
+ if (slide.imageUrl) {
474
+ try {
475
+ s.addImage({
476
+ path: slide.imageUrl,
477
+ x: 1, y: 1.5, w: 11, h: 5,
478
+ sizing: { type: 'contain', w: 11, h: 5 },
479
+ });
480
+ } catch {}
481
+ }
482
+
483
+ if (slide.caption) {
484
+ s.addText(slide.caption, {
485
+ x: 0.5, y: 6.5, w: 12, h: 0.5,
486
+ fontSize: 12, color: theme.text, align: 'center', italic: true,
487
+ });
488
+ }
489
+ }
490
+
491
+ _addSectionSlide(pres, slide, theme, slideNum, total) {
492
+ const s = pres.addSlide();
493
+ s.background = { color: theme.bg2 };
494
+
495
+ s.addShape(pres.ShapeType.rect, {
496
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
497
+ });
498
+
499
+ s.addText(slide.title || 'Section', {
500
+ x: 0.5, y: 2.5, w: 12, h: 1.5,
501
+ fontSize: 38, bold: true, color: theme.title, fontFace: 'Arial',
502
+ });
503
+
504
+ if (slide.subtitle) {
505
+ s.addText(slide.subtitle, {
506
+ x: 0.5, y: 4, w: 10, h: 0.8,
507
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
508
+ });
509
+ }
510
+
511
+ s.addShape(pres.ShapeType.rect, {
512
+ x: 0.5, y: 4.9, w: 2.5, h: 0.04, fill: { color: theme.accent },
513
+ });
514
+ }
515
+
516
+ _addThankYouSlide(pres, options, theme) {
517
+ const s = pres.addSlide();
518
+ s.background = { color: theme.bg };
519
+
520
+ s.addShape(pres.ShapeType.rect, {
521
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
522
+ });
523
+
524
+ s.addText('Thank You', {
525
+ x: 0, y: 2, w: 13, h: 1.5,
526
+ fontSize: 48, bold: true, color: theme.title, align: 'center', fontFace: 'Arial',
527
+ });
528
+
529
+ s.addShape(pres.ShapeType.rect, {
530
+ x: 5.5, y: 3.6, w: 2, h: 0.04, fill: { color: theme.accent },
531
+ });
532
+
533
+ if (options.contact) {
534
+ s.addText(options.contact, {
535
+ x: 0, y: 4, w: 13, h: 0.8,
536
+ fontSize: 16, color: theme.text, align: 'center', fontFace: 'Arial',
537
+ });
538
+ }
539
+
540
+ s.addText('Created with Squidclaw AI ๐Ÿฆ‘', {
541
+ x: 0, y: 6.5, w: 13, h: 0.5,
542
+ fontSize: 11, color: theme.border, align: 'center', italic: true,
543
+ });
544
+ }
545
+
546
+ // โ”€โ”€ PARSER โ”€โ”€
547
+
548
+ /**
549
+ * Parse AI content into structured slides
550
+ * Supports: ## Title, - bullets, [chart:bar], [table], [stats], [timeline], [quote], [compare], [section]
551
+ */
552
+ static parseContent(text) {
553
+ const slides = [];
554
+ const sections = text.split(/^## /gm).filter(s => s.trim());
555
+
556
+ for (const section of sections) {
557
+ const lines = section.trim().split('\n');
558
+ const titleLine = lines[0].trim();
559
+
560
+ // Detect slide type from markers
561
+ let slide = { title: titleLine, type: 'content', bullets: [], content: '' };
562
+
563
+ // [chart:bar] or [chart:pie] etc
564
+ const chartMatch = titleLine.match(/\[chart:(\w+)\]/i) || lines.some(l => l.match(/\[chart:(\w+)\]/i));
565
+ if (chartMatch || lines.some(l => /\[chart/i.test(l))) {
566
+ slide.type = 'chart';
567
+ slide.title = titleLine.replace(/\[chart:\w+\]/i, '').trim();
568
+ const typeMatch = section.match(/\[chart:(\w+)\]/i);
569
+ slide.chartType = typeMatch ? typeMatch[1] : 'bar';
570
+ slide.chartData = this._parseChartData(lines.slice(1));
571
+ slides.push(slide);
572
+ continue;
573
+ }
574
+
575
+ // [table]
576
+ if (lines.some(l => /\[table\]|^\|/.test(l))) {
577
+ slide.type = 'table';
578
+ slide.title = titleLine.replace(/\[table\]/i, '').trim();
579
+ slide.tableData = this._parseTableData(lines.slice(1));
580
+ slides.push(slide);
581
+ continue;
582
+ }
583
+
584
+ // [stats]
585
+ if (lines.some(l => /\[stats?\]/i.test(l))) {
586
+ slide.type = 'stats';
587
+ slide.title = titleLine.replace(/\[stats?\]/i, '').trim();
588
+ slide.stats = this._parseStats(lines.slice(1));
589
+ slides.push(slide);
590
+ continue;
591
+ }
592
+
593
+ // [timeline]
594
+ if (lines.some(l => /\[timeline\]/i.test(l))) {
595
+ slide.type = 'timeline';
596
+ slide.title = titleLine.replace(/\[timeline\]/i, '').trim();
597
+ slide.events = this._parseTimeline(lines.slice(1));
598
+ slides.push(slide);
599
+ continue;
600
+ }
601
+
602
+ // [quote]
603
+ if (lines.some(l => /\[quote\]/i.test(l)) || titleLine.includes('[quote]')) {
604
+ slide.type = 'quote';
605
+ slide.title = titleLine.replace(/\[quote\]/i, '').trim();
606
+ const quoteLines = lines.slice(1).filter(l => !l.includes('[quote]'));
607
+ const authorLine = quoteLines.find(l => l.startsWith('โ€”') || l.startsWith('-โ€”') || l.startsWith('- Author:'));
608
+ slide.quote = quoteLines.filter(l => l !== authorLine).map(l => l.replace(/^[-โ€ข*]\s*/, '').trim()).filter(Boolean).join(' ');
609
+ slide.author = authorLine ? authorLine.replace(/^[-โ€”โ€ข*\s]*Author:\s*|^[-โ€”]\s*/i, '').trim() : '';
610
+ slides.push(slide);
611
+ continue;
612
+ }
613
+
614
+ // [compare] or [vs]
615
+ if (lines.some(l => /\[compare\]|\[vs\]/i.test(l))) {
616
+ slide.type = 'comparison';
617
+ slide.title = titleLine.replace(/\[compare\]|\[vs\]/i, '').trim();
618
+ const parsed = this._parseComparison(lines.slice(1));
619
+ slide.left = parsed.left;
620
+ slide.right = parsed.right;
621
+ slides.push(slide);
622
+ continue;
623
+ }
624
+
625
+ // [section]
626
+ if (titleLine.includes('[section]') || lines.some(l => /\[section\]/i.test(l))) {
627
+ slide.type = 'section';
628
+ slide.title = titleLine.replace(/\[section\]/i, '').trim();
629
+ slide.subtitle = lines.slice(1).filter(l => !l.includes('[section]')).map(l => l.trim()).filter(Boolean).join(' ');
630
+ slides.push(slide);
631
+ continue;
632
+ }
633
+
634
+ // Default: bullets
635
+ for (let i = 1; i < lines.length; i++) {
636
+ const line = lines[i].trim();
637
+ if (line.startsWith('- ') || line.startsWith('โ€ข ') || line.startsWith('* ')) {
638
+ slide.bullets.push(line.replace(/^[-โ€ข*]\s*/, ''));
639
+ } else if (line) {
640
+ slide.content += (slide.content ? '\n' : '') + line;
641
+ }
642
+ }
643
+ if (!slide.bullets.length && slide.content) slide.bullets = null;
644
+ slides.push(slide);
645
+ }
646
+
647
+ return slides;
648
+ }
649
+
650
+ static _parseChartData(lines) {
651
+ const data = [];
652
+ let current = null;
653
+ const labels = [];
654
+ const values = [];
655
+
656
+ for (const line of lines) {
657
+ const trimmed = line.trim();
658
+ if (!trimmed || trimmed.includes('[chart')) continue;
659
+
660
+ // Format: "Label: value" or "Label | value"
661
+ const match = trimmed.match(/^[-โ€ข*]?\s*(.+?)[\s:|\-โ†’]+(\d+[\d,.]*%?)\s*$/);
662
+ if (match) {
663
+ labels.push(match[1].trim());
664
+ values.push(parseFloat(match[2].replace(/[,%]/g, '')));
665
+ }
666
+ }
667
+
668
+ if (labels.length > 0) {
669
+ data.push({ name: 'Data', labels, values });
670
+ }
671
+ return data.length > 0 ? data : [{ name: 'Sample', labels: ['A', 'B', 'C'], values: [30, 50, 20] }];
672
+ }
673
+
674
+ static _parseTableData(lines) {
675
+ const rows = [];
676
+ for (const line of lines) {
677
+ const trimmed = line.trim();
678
+ if (!trimmed || trimmed.includes('[table]') || /^[-|:]+$/.test(trimmed)) continue;
679
+ if (trimmed.startsWith('|')) {
680
+ const cells = trimmed.split('|').filter(c => c.trim()).map(c => c.trim());
681
+ if (cells.length > 0) rows.push(cells);
682
+ } else if (trimmed.includes(',') || trimmed.includes('\t')) {
683
+ rows.push(trimmed.split(/[,\t]+/).map(c => c.trim()));
684
+ }
685
+ }
686
+ return rows.length > 0 ? rows : [['Col 1', 'Col 2'], ['Data', 'Data']];
687
+ }
688
+
689
+ static _parseStats(lines) {
690
+ const stats = [];
691
+ for (const line of lines) {
692
+ const trimmed = line.trim();
693
+ if (!trimmed || trimmed.includes('[stat')) continue;
694
+ // Format: "๐Ÿ“Š 100M+ โ€” Active users" or "100M: Active users"
695
+ const match = trimmed.match(/^[-โ€ข*]?\s*([^\w]*?)?\s*([\d,.]+[%KMBkm+]*)\s*[-:โ€”|]+\s*(.+)/);
696
+ if (match) {
697
+ stats.push({
698
+ icon: match[1]?.trim() || undefined,
699
+ value: match[2].trim(),
700
+ label: match[3].trim(),
701
+ });
702
+ }
703
+ }
704
+ return stats.length > 0 ? stats : [{ value: '?', label: 'No data' }];
705
+ }
706
+
707
+ static _parseTimeline(lines) {
708
+ const events = [];
709
+ for (const line of lines) {
710
+ const trimmed = line.trim();
711
+ if (!trimmed || trimmed.includes('[timeline]')) continue;
712
+ const match = trimmed.match(/^[-โ€ข*]?\s*(\d{4}[s]?|Phase \d+|Q\d|Step \d+)\s*[-:โ€”|]+\s*(.+)/i);
713
+ if (match) {
714
+ events.push({ date: match[1].trim(), text: match[2].trim() });
715
+ }
716
+ }
717
+ return events.length > 0 ? events : [{ date: '???', text: 'No events' }];
718
+ }
719
+
720
+ static _parseComparison(lines) {
721
+ const left = { title: 'Option A', points: [] };
722
+ const right = { title: 'Option B', points: [] };
723
+ let side = 'left';
724
+
725
+ for (const line of lines) {
726
+ const trimmed = line.trim();
727
+ if (!trimmed || trimmed.includes('[compare]') || trimmed.includes('[vs]')) continue;
728
+
729
+ if (trimmed.toLowerCase().includes('vs') || trimmed === '---') {
730
+ side = 'right';
731
+ continue;
732
+ }
733
+
734
+ const target = side === 'left' ? left : right;
735
+ if (trimmed.startsWith('###') || trimmed.startsWith('**')) {
736
+ target.title = trimmed.replace(/^[#*\s]+|[*]+$/g, '');
737
+ } else if (trimmed.startsWith('- ') || trimmed.startsWith('โ€ข ')) {
738
+ target.points.push(trimmed.replace(/^[-โ€ข]\s*/, ''));
739
+ }
740
+ }
741
+
742
+ return { left, right };
743
+ }
744
+ }
@@ -23,7 +23,9 @@ export class ToolRouter {
23
23
  getToolDescriptions() {
24
24
  const tools = [
25
25
  '## Available Tools',
26
- 'You can use tools by including special tags in your response:',
26
+ 'You can use tools by including special tags in your response.',
27
+ 'IMPORTANT: You CAN send files, images, voice notes, and documents in this chat. The system handles delivery automatically.',
28
+ 'When asked to create files (PowerPoint, etc), USE THE TOOL โ€” do NOT say you cannot send files.',
27
29
  '',
28
30
  '### Web Search',
29
31
  '---TOOL:search:your search query---',
@@ -59,6 +61,24 @@ export class ToolRouter {
59
61
  'Send an email.');
60
62
  }
61
63
 
64
+ tools.push('', '### Create PowerPoint (SENDS AS FILE IN CHAT!)',
65
+ '---TOOL:pptx_slides:Title|theme|slides content---',
66
+ 'Creates a .pptx file and SENDS IT directly. You MUST use this when asked for PPT/presentation.',
67
+ 'Format: title|theme|slides. Themes: dark, light, blue, green, corporate, red, gradient, minimal, saudi, ocean.',
68
+ '',
69
+ 'SLIDE TYPES (use tags in ## title):',
70
+ '- Normal: ## Title then - bullet points',
71
+ '- Chart: ## Revenue [chart:bar] then - Label: value (types: bar, pie, line, doughnut, area)',
72
+ '- Table: ## Data [table] then | Col1 | Col2 | rows',
73
+ '- Stats: ## Metrics [stats] then - ๐Ÿ“Š 100M โ€” Active users',
74
+ '- Timeline: ## History [timeline] then - 2020: Event happened',
75
+ '- Quote: ## [quote] then quote text then โ€” Author Name',
76
+ '- Compare: ## Comparison [compare] then left items, ---, right items',
77
+ '- Section: ## New Section [section]',
78
+ '',
79
+ 'Example:',
80
+ '---TOOL:pptx_slides:AI Report|dark|## Introduction\n- AI is transforming industries\n- Revenue growing 40% YoY\n\n## Growth [chart:bar]\n- 2020: 50\n- 2021: 80\n- 2022: 120\n- 2023: 200\n\n## Key Stats [stats]\n- ๐ŸŒ 195 โ€” Countries using AI\n- ๐Ÿ’ฐ $500B โ€” Market size\n- ๐Ÿš€ 40% โ€” Annual growth---');
81
+
62
82
  tools.push('', '### Allow User',
63
83
  '---TOOL:allow:user_id_or_phone---',
64
84
  'Add someone to the allowlist so they can message you.',
@@ -221,6 +241,56 @@ export class ToolRouter {
221
241
  }
222
242
  break;
223
243
  }
244
+ case 'pptx':
245
+ case 'pptx_slides':
246
+ case 'powerpoint':
247
+ case 'presentation': {
248
+ try {
249
+ const { PptxGenerator } = await import('./pptx.js');
250
+ const gen = new PptxGenerator();
251
+
252
+ // Parse: title|theme|content or just content
253
+ const parts = toolArg.split('|');
254
+ let title, theme, slideContent;
255
+
256
+ if (parts.length >= 3) {
257
+ title = parts[0].trim();
258
+ theme = parts[1].trim();
259
+ slideContent = parts.slice(2).join('|');
260
+ } else if (parts.length === 2) {
261
+ title = parts[0].trim();
262
+ slideContent = parts[1];
263
+ theme = 'corporate';
264
+ } else {
265
+ // Try to extract title from first ## heading
266
+ const firstH2 = toolArg.match(/^##\s+(.+)/m);
267
+ title = firstH2 ? firstH2[1] : 'Presentation';
268
+ slideContent = toolArg;
269
+ theme = 'corporate';
270
+ }
271
+
272
+ const slides = PptxGenerator.parseContent(slideContent);
273
+ if (slides.length === 0) {
274
+ toolResult = 'No slides found. Use ## headings and - bullet points.';
275
+ break;
276
+ }
277
+
278
+ const result = await gen.create(title, slides, { theme });
279
+
280
+ // Return file path for sending
281
+ return {
282
+ toolUsed: true,
283
+ toolName: 'pptx',
284
+ toolResult: 'PowerPoint created: ' + result.filename + ' (' + result.slideCount + ' slides)',
285
+ filePath: result.filepath,
286
+ fileName: result.filename,
287
+ cleanResponse
288
+ };
289
+ } catch (err) {
290
+ toolResult = 'PowerPoint failed: ' + err.message;
291
+ }
292
+ break;
293
+ }
224
294
  case 'allow': {
225
295
  try {
226
296
  const { AllowlistManager } = await import('../features/allowlist-manager.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "๐Ÿฆ‘ AI agent platform โ€” human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -49,6 +49,7 @@
49
49
  "node-edge-tts": "^1.2.10",
50
50
  "pdfjs-dist": "^5.4.624",
51
51
  "pino": "^10.3.1",
52
+ "pptxgenjs": "^4.0.1",
52
53
  "puppeteer-core": "^24.37.5",
53
54
  "qrcode-terminal": "^0.12.0",
54
55
  "sharp": "^0.34.5",