squidclaw 0.1.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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/squidclaw.js +512 -0
- package/lib/ai/gateway.js +283 -0
- package/lib/ai/prompt-builder.js +149 -0
- package/lib/api/server.js +235 -0
- package/lib/behavior/engine.js +187 -0
- package/lib/channels/hub-media.js +128 -0
- package/lib/channels/hub.js +89 -0
- package/lib/channels/whatsapp/manager.js +319 -0
- package/lib/channels/whatsapp/media.js +228 -0
- package/lib/cli/agent-cmd.js +182 -0
- package/lib/cli/brain-cmd.js +49 -0
- package/lib/cli/broadcast-cmd.js +28 -0
- package/lib/cli/channels-cmd.js +157 -0
- package/lib/cli/config-cmd.js +26 -0
- package/lib/cli/conversations-cmd.js +27 -0
- package/lib/cli/engine-cmd.js +115 -0
- package/lib/cli/handoff-cmd.js +26 -0
- package/lib/cli/hours-cmd.js +38 -0
- package/lib/cli/key-cmd.js +62 -0
- package/lib/cli/knowledge-cmd.js +59 -0
- package/lib/cli/memory-cmd.js +50 -0
- package/lib/cli/platform-cmd.js +51 -0
- package/lib/cli/setup.js +226 -0
- package/lib/cli/stats-cmd.js +66 -0
- package/lib/cli/tui.js +308 -0
- package/lib/cli/update-cmd.js +25 -0
- package/lib/cli/webhook-cmd.js +40 -0
- package/lib/core/agent-manager.js +83 -0
- package/lib/core/agent.js +162 -0
- package/lib/core/config.js +172 -0
- package/lib/core/logger.js +43 -0
- package/lib/engine.js +117 -0
- package/lib/features/heartbeat.js +71 -0
- package/lib/storage/interface.js +56 -0
- package/lib/storage/sqlite.js +409 -0
- package/package.json +48 -0
- package/templates/BEHAVIOR.md +42 -0
- package/templates/IDENTITY.md +7 -0
- package/templates/RULES.md +9 -0
- package/templates/SOUL.md +19 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 WhatsApp Manager
|
|
3
|
+
* Multi-tenant WhatsApp session manager using Baileys
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { makeWASocket, useMultiFileAuthState, makeCacheableSignalKeyStore, DisconnectReason } from '@whiskeysockets/baileys';
|
|
7
|
+
import { Boom } from '@hapi/boom';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { mkdirSync } from 'fs';
|
|
10
|
+
import { logger } from '../../core/logger.js';
|
|
11
|
+
import pino from 'pino';
|
|
12
|
+
|
|
13
|
+
const baileyLogger = pino({ level: 'silent' });
|
|
14
|
+
|
|
15
|
+
export class WhatsAppManager {
|
|
16
|
+
constructor(config, agentManager, home) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.agentManager = agentManager;
|
|
19
|
+
this.home = home;
|
|
20
|
+
this.sessions = new Map(); // agentId → { socket, status }
|
|
21
|
+
this.onMessage = null; // callback: (agentId, contactId, message, metadata) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start WhatsApp session for an agent
|
|
26
|
+
*/
|
|
27
|
+
async startSession(agentId) {
|
|
28
|
+
if (this.sessions.has(agentId)) {
|
|
29
|
+
const existing = this.sessions.get(agentId);
|
|
30
|
+
if (existing.status === 'connected') {
|
|
31
|
+
logger.info('whatsapp', `Session for agent ${agentId} already connected`);
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const authDir = join(this.home, 'channels', 'whatsapp', agentId, 'auth');
|
|
37
|
+
mkdirSync(authDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
40
|
+
|
|
41
|
+
const socket = makeWASocket({
|
|
42
|
+
auth: {
|
|
43
|
+
creds: state.creds,
|
|
44
|
+
keys: makeCacheableSignalKeyStore(state.keys, baileyLogger),
|
|
45
|
+
},
|
|
46
|
+
printQRInTerminal: false,
|
|
47
|
+
logger: baileyLogger,
|
|
48
|
+
browser: ['Squidclaw', 'Chrome', '120.0.0'],
|
|
49
|
+
connectTimeoutMs: 30000,
|
|
50
|
+
retryRequestDelayMs: 2000,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const session = { socket, status: 'connecting', agentId };
|
|
54
|
+
this.sessions.set(agentId, session);
|
|
55
|
+
|
|
56
|
+
// Handle connection updates
|
|
57
|
+
socket.ev.on('connection.update', (update) => {
|
|
58
|
+
const { connection, lastDisconnect } = update;
|
|
59
|
+
|
|
60
|
+
if (connection === 'open') {
|
|
61
|
+
session.status = 'connected';
|
|
62
|
+
logger.info('whatsapp', `✅ Agent ${agentId} WhatsApp connected`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (connection === 'close') {
|
|
66
|
+
const statusCode = new Boom(lastDisconnect?.error)?.output?.statusCode;
|
|
67
|
+
session.status = 'disconnected';
|
|
68
|
+
|
|
69
|
+
if (statusCode === DisconnectReason.loggedOut) {
|
|
70
|
+
logger.warn('whatsapp', `Agent ${agentId} logged out — needs re-pairing`);
|
|
71
|
+
this.sessions.delete(agentId);
|
|
72
|
+
} else {
|
|
73
|
+
// Auto-reconnect
|
|
74
|
+
logger.info('whatsapp', `Agent ${agentId} disconnected (${statusCode}), reconnecting...`);
|
|
75
|
+
setTimeout(() => this.startSession(agentId), 5000);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Save credentials on update
|
|
81
|
+
socket.ev.on('creds.update', saveCreds);
|
|
82
|
+
|
|
83
|
+
// Handle incoming messages
|
|
84
|
+
socket.ev.on('messages.upsert', async ({ messages: msgs }) => {
|
|
85
|
+
for (const msg of msgs) {
|
|
86
|
+
if (msg.key.fromMe) continue; // Skip our own messages
|
|
87
|
+
if (!msg.message) continue;
|
|
88
|
+
|
|
89
|
+
const contactId = msg.key.remoteJid;
|
|
90
|
+
if (!contactId || contactId === 'status@broadcast') continue;
|
|
91
|
+
|
|
92
|
+
// Extract message content
|
|
93
|
+
const content = this._extractContent(msg);
|
|
94
|
+
if (!content) continue;
|
|
95
|
+
|
|
96
|
+
const metadata = {
|
|
97
|
+
_rawMessage: msg,
|
|
98
|
+
pushName: msg.pushName,
|
|
99
|
+
messageId: msg.key.id,
|
|
100
|
+
timestamp: msg.messageTimestamp,
|
|
101
|
+
isGroup: contactId.endsWith('@g.us'),
|
|
102
|
+
mediaType: this._getMediaType(msg),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
logger.info('whatsapp', `📱 ${agentId}: Message from ${msg.pushName || contactId}: ${content.substring(0, 50)}...`);
|
|
106
|
+
|
|
107
|
+
// Route to message handler
|
|
108
|
+
if (this.onMessage) {
|
|
109
|
+
try {
|
|
110
|
+
await this.onMessage(agentId, contactId, content, metadata);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.error('whatsapp', `Error processing message: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return session;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Send a message (with split support)
|
|
123
|
+
*/
|
|
124
|
+
async sendMessage(agentId, contactId, text) {
|
|
125
|
+
const session = this.sessions.get(agentId);
|
|
126
|
+
if (!session?.socket || session.status !== 'connected') {
|
|
127
|
+
throw new Error(`WhatsApp not connected for agent ${agentId}`);
|
|
128
|
+
}
|
|
129
|
+
await session.socket.sendMessage(contactId, { text });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Send multiple messages with delays (human-like)
|
|
134
|
+
*/
|
|
135
|
+
async sendMessages(agentId, contactId, messages, delayMs = 700) {
|
|
136
|
+
for (let i = 0; i < messages.length; i++) {
|
|
137
|
+
if (i > 0) {
|
|
138
|
+
// Random delay between 500-1000ms
|
|
139
|
+
const delay = delayMs + Math.random() * 500 - 250;
|
|
140
|
+
await new Promise(r => setTimeout(r, Math.max(300, delay)));
|
|
141
|
+
}
|
|
142
|
+
await this.sendMessage(agentId, contactId, messages[i]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Send a reaction emoji
|
|
148
|
+
*/
|
|
149
|
+
async sendReaction(agentId, contactId, messageId, emoji) {
|
|
150
|
+
const session = this.sessions.get(agentId);
|
|
151
|
+
if (!session?.socket || session.status !== 'connected') return;
|
|
152
|
+
|
|
153
|
+
await session.socket.sendMessage(contactId, {
|
|
154
|
+
react: { text: emoji, key: { remoteJid: contactId, id: messageId } },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Request a pairing code for WhatsApp login
|
|
160
|
+
*/
|
|
161
|
+
async requestPairingCode(agentId, phoneNumber) {
|
|
162
|
+
const session = this.sessions.get(agentId);
|
|
163
|
+
if (!session?.socket) {
|
|
164
|
+
// Start a session first
|
|
165
|
+
await this.startSession(agentId);
|
|
166
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sock = this.sessions.get(agentId)?.socket;
|
|
170
|
+
if (!sock) throw new Error('Could not create WhatsApp session');
|
|
171
|
+
|
|
172
|
+
const clean = phoneNumber.replace(/[^0-9]/g, '');
|
|
173
|
+
const code = await sock.requestPairingCode(clean);
|
|
174
|
+
return code;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get all session statuses
|
|
179
|
+
*/
|
|
180
|
+
getStatuses() {
|
|
181
|
+
const statuses = {};
|
|
182
|
+
for (const [agentId, session] of this.sessions) {
|
|
183
|
+
statuses[agentId] = {
|
|
184
|
+
status: session.status,
|
|
185
|
+
connected: session.status === 'connected',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return statuses;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stop a session
|
|
193
|
+
*/
|
|
194
|
+
async stopSession(agentId) {
|
|
195
|
+
const session = this.sessions.get(agentId);
|
|
196
|
+
if (session?.socket) {
|
|
197
|
+
try {
|
|
198
|
+
session.socket.end(undefined);
|
|
199
|
+
} catch {}
|
|
200
|
+
this.sessions.delete(agentId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Stop all sessions
|
|
206
|
+
*/
|
|
207
|
+
async stopAll() {
|
|
208
|
+
for (const agentId of this.sessions.keys()) {
|
|
209
|
+
await this.stopSession(agentId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Private helpers ──
|
|
214
|
+
|
|
215
|
+
_extractContent(msg) {
|
|
216
|
+
const m = msg.message;
|
|
217
|
+
if (!m) return null;
|
|
218
|
+
|
|
219
|
+
// Text message
|
|
220
|
+
if (m.conversation) return m.conversation;
|
|
221
|
+
if (m.extendedTextMessage?.text) return m.extendedTextMessage.text;
|
|
222
|
+
|
|
223
|
+
// Image with caption
|
|
224
|
+
if (m.imageMessage?.caption) return `[📸 Image] ${m.imageMessage.caption}`;
|
|
225
|
+
if (m.imageMessage) return '[📸 Image]';
|
|
226
|
+
|
|
227
|
+
// Video
|
|
228
|
+
if (m.videoMessage?.caption) return `[🎥 Video] ${m.videoMessage.caption}`;
|
|
229
|
+
if (m.videoMessage) return '[🎥 Video]';
|
|
230
|
+
|
|
231
|
+
// Audio/Voice
|
|
232
|
+
if (m.audioMessage) return '[🎤 Voice note]';
|
|
233
|
+
|
|
234
|
+
// Document
|
|
235
|
+
if (m.documentMessage) return `[📄 Document: ${m.documentMessage.fileName || 'file'}]`;
|
|
236
|
+
|
|
237
|
+
// Location
|
|
238
|
+
if (m.locationMessage) {
|
|
239
|
+
return `[📍 Location: ${m.locationMessage.degreesLatitude}, ${m.locationMessage.degreesLongitude}]`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Contact
|
|
243
|
+
if (m.contactMessage) return `[👤 Contact: ${m.contactMessage.displayName || 'contact'}]`;
|
|
244
|
+
|
|
245
|
+
// Sticker
|
|
246
|
+
if (m.stickerMessage) return '[🎭 Sticker]';
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_getMediaType(msg) {
|
|
252
|
+
const m = msg.message;
|
|
253
|
+
if (!m) return null;
|
|
254
|
+
if (m.imageMessage) return 'image';
|
|
255
|
+
if (m.videoMessage) return 'video';
|
|
256
|
+
if (m.audioMessage) return 'audio';
|
|
257
|
+
if (m.documentMessage) return 'document';
|
|
258
|
+
if (m.locationMessage) return 'location';
|
|
259
|
+
if (m.contactMessage) return 'contact';
|
|
260
|
+
if (m.stickerMessage) return 'sticker';
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Voice Note Sending ──
|
|
266
|
+
WhatsAppManager.prototype.sendVoiceNote = async function(agentId, contactId, audioBuffer) {
|
|
267
|
+
const session = this.sessions.get(agentId);
|
|
268
|
+
if (!session?.socket || session.status !== 'connected') {
|
|
269
|
+
throw new Error(`WhatsApp not connected for agent ${agentId}`);
|
|
270
|
+
}
|
|
271
|
+
await session.socket.sendMessage(contactId, {
|
|
272
|
+
audio: audioBuffer,
|
|
273
|
+
mimetype: 'audio/mp4',
|
|
274
|
+
ptt: true, // push to talk = voice note
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ── Image Sending ──
|
|
279
|
+
WhatsAppManager.prototype.sendImage = async function(agentId, contactId, imageBuffer, caption) {
|
|
280
|
+
const session = this.sessions.get(agentId);
|
|
281
|
+
if (!session?.socket || session.status !== 'connected') {
|
|
282
|
+
throw new Error(`WhatsApp not connected for agent ${agentId}`);
|
|
283
|
+
}
|
|
284
|
+
await session.socket.sendMessage(contactId, {
|
|
285
|
+
image: imageBuffer,
|
|
286
|
+
caption: caption || '',
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// ── Location Sending ──
|
|
291
|
+
WhatsAppManager.prototype.sendLocation = async function(agentId, contactId, lat, lng, name) {
|
|
292
|
+
const session = this.sessions.get(agentId);
|
|
293
|
+
if (!session?.socket || session.status !== 'connected') return;
|
|
294
|
+
await session.socket.sendMessage(contactId, {
|
|
295
|
+
location: { degreesLatitude: lat, degreesLongitude: lng, name: name || '' },
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ── Contact Card Sending ──
|
|
300
|
+
WhatsAppManager.prototype.sendContact = async function(agentId, contactId, vcard) {
|
|
301
|
+
const session = this.sessions.get(agentId);
|
|
302
|
+
if (!session?.socket || session.status !== 'connected') return;
|
|
303
|
+
await session.socket.sendMessage(contactId, {
|
|
304
|
+
contacts: { contacts: [{ vcard }] },
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// ── Download Media from Message ──
|
|
309
|
+
WhatsAppManager.prototype.downloadMedia = async function(agentId, message) {
|
|
310
|
+
const session = this.sessions.get(agentId);
|
|
311
|
+
if (!session?.socket) return null;
|
|
312
|
+
try {
|
|
313
|
+
const { downloadMediaMessage } = await import('@whiskeysockets/baileys');
|
|
314
|
+
return await downloadMediaMessage(message, 'buffer', {});
|
|
315
|
+
} catch (err) {
|
|
316
|
+
logger.error('whatsapp', `Media download failed: ${err.message}`);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 WhatsApp Media Handler
|
|
3
|
+
* Voice notes (receive/send), images, documents
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { logger } from '../../core/logger.js';
|
|
9
|
+
|
|
10
|
+
export class MediaHandler {
|
|
11
|
+
constructor(config, home) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.home = home;
|
|
14
|
+
this.mediaDir = join(home, 'media');
|
|
15
|
+
mkdirSync(this.mediaDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Download media from a WhatsApp message
|
|
20
|
+
*/
|
|
21
|
+
async downloadMedia(socket, message) {
|
|
22
|
+
try {
|
|
23
|
+
const { downloadMediaMessage } = await import('@whiskeysockets/baileys');
|
|
24
|
+
const buffer = await downloadMediaMessage(message, 'buffer', {});
|
|
25
|
+
return buffer;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
logger.error('media', `Failed to download media: ${err.message}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Transcribe a voice note using AI provider
|
|
34
|
+
*/
|
|
35
|
+
async transcribeAudio(buffer, config) {
|
|
36
|
+
// Try OpenAI Whisper first
|
|
37
|
+
const openaiKey = config.ai?.providers?.openai?.key;
|
|
38
|
+
if (openaiKey) {
|
|
39
|
+
return this._transcribeWhisper(buffer, openaiKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Try Groq Whisper
|
|
43
|
+
const groqKey = config.ai?.providers?.groq?.key;
|
|
44
|
+
if (groqKey) {
|
|
45
|
+
return this._transcribeGroqWhisper(buffer, groqKey);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logger.warn('media', 'No transcription provider available (need OpenAI or Groq key)');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async _transcribeWhisper(buffer, apiKey) {
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
const blob = new Blob([buffer], { type: 'audio/ogg' });
|
|
55
|
+
formData.append('file', blob, 'audio.ogg');
|
|
56
|
+
formData.append('model', 'whisper-1');
|
|
57
|
+
|
|
58
|
+
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
61
|
+
body: formData,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) throw new Error(`Whisper API error: ${res.status}`);
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
return data.text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _transcribeGroqWhisper(buffer, apiKey) {
|
|
70
|
+
const formData = new FormData();
|
|
71
|
+
const blob = new Blob([buffer], { type: 'audio/ogg' });
|
|
72
|
+
formData.append('file', blob, 'audio.ogg');
|
|
73
|
+
formData.append('model', 'whisper-large-v3');
|
|
74
|
+
|
|
75
|
+
const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
78
|
+
body: formData,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!res.ok) throw new Error(`Groq Whisper error: ${res.status}`);
|
|
82
|
+
const data = await res.json();
|
|
83
|
+
return data.text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate voice note from text using TTS
|
|
88
|
+
*/
|
|
89
|
+
async textToSpeech(text, voiceConfig = {}) {
|
|
90
|
+
const provider = voiceConfig.provider || 'edge-tts';
|
|
91
|
+
|
|
92
|
+
if (provider === 'edge-tts') {
|
|
93
|
+
return this._edgeTTS(text, voiceConfig.name || 'en-US-AriaNeural');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (provider === 'openai') {
|
|
97
|
+
return this._openaiTTS(text, voiceConfig);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async _edgeTTS(text, voiceName) {
|
|
104
|
+
try {
|
|
105
|
+
const { MsEdgeTTS } = await import('node-edge-tts');
|
|
106
|
+
const tts = new MsEdgeTTS();
|
|
107
|
+
await tts.setMetadata(voiceName, 'audio-24khz-96kbitrate-mono-mp3');
|
|
108
|
+
const readable = tts.toStream(text);
|
|
109
|
+
|
|
110
|
+
const chunks = [];
|
|
111
|
+
for await (const chunk of readable) {
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
}
|
|
114
|
+
return Buffer.concat(chunks);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.error('media', `Edge TTS failed: ${err.message}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _openaiTTS(text, voiceConfig) {
|
|
122
|
+
const apiKey = this.config.ai?.providers?.openai?.key;
|
|
123
|
+
if (!apiKey) return null;
|
|
124
|
+
|
|
125
|
+
const res = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
model: 'tts-1',
|
|
133
|
+
input: text,
|
|
134
|
+
voice: voiceConfig.voice || 'nova',
|
|
135
|
+
response_format: 'opus',
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!res.ok) throw new Error(`OpenAI TTS error: ${res.status}`);
|
|
140
|
+
return Buffer.from(await res.arrayBuffer());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Analyze an image using vision API
|
|
145
|
+
*/
|
|
146
|
+
async analyzeImage(buffer, prompt, config) {
|
|
147
|
+
// Try Anthropic Vision
|
|
148
|
+
const anthropicKey = config.ai?.providers?.anthropic?.key;
|
|
149
|
+
if (anthropicKey) {
|
|
150
|
+
return this._anthropicVision(buffer, prompt, anthropicKey, config);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try OpenAI Vision
|
|
154
|
+
const openaiKey = config.ai?.providers?.openai?.key;
|
|
155
|
+
if (openaiKey) {
|
|
156
|
+
return this._openaiVision(buffer, prompt, openaiKey);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 'I can see you sent an image, but I don\'t have image analysis configured right now.';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async _anthropicVision(buffer, prompt, apiKey, config) {
|
|
163
|
+
const base64 = buffer.toString('base64');
|
|
164
|
+
const isOAuth = apiKey.includes('sk-ant-oat');
|
|
165
|
+
|
|
166
|
+
const headers = {
|
|
167
|
+
'content-type': 'application/json',
|
|
168
|
+
'anthropic-version': '2023-06-01',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (isOAuth) {
|
|
172
|
+
headers['authorization'] = `Bearer ${apiKey}`;
|
|
173
|
+
headers['anthropic-beta'] = 'claude-code-20250219,oauth-2025-04-20';
|
|
174
|
+
headers['user-agent'] = 'squidclaw/0.1.0';
|
|
175
|
+
headers['x-app'] = 'squidclaw';
|
|
176
|
+
headers['anthropic-dangerous-direct-browser-access'] = 'true';
|
|
177
|
+
} else {
|
|
178
|
+
headers['x-api-key'] = apiKey;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers,
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
model: 'claude-sonnet-4-20250514',
|
|
186
|
+
max_tokens: 1024,
|
|
187
|
+
messages: [{
|
|
188
|
+
role: 'user',
|
|
189
|
+
content: [
|
|
190
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
|
|
191
|
+
{ type: 'text', text: prompt || 'Describe this image briefly.' },
|
|
192
|
+
],
|
|
193
|
+
}],
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!res.ok) throw new Error(`Vision API error: ${res.status}`);
|
|
198
|
+
const data = await res.json();
|
|
199
|
+
return data.content?.[0]?.text || 'Could not analyze image';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _openaiVision(buffer, prompt, apiKey) {
|
|
203
|
+
const base64 = buffer.toString('base64');
|
|
204
|
+
|
|
205
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
'content-type': 'application/json',
|
|
209
|
+
'authorization': `Bearer ${apiKey}`,
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
model: 'gpt-4o',
|
|
213
|
+
max_tokens: 1024,
|
|
214
|
+
messages: [{
|
|
215
|
+
role: 'user',
|
|
216
|
+
content: [
|
|
217
|
+
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64}` } },
|
|
218
|
+
{ type: 'text', text: prompt || 'Describe this image briefly.' },
|
|
219
|
+
],
|
|
220
|
+
}],
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!res.ok) throw new Error(`Vision API error: ${res.status}`);
|
|
225
|
+
const data = await res.json();
|
|
226
|
+
return data.choices?.[0]?.message?.content || 'Could not analyze image';
|
|
227
|
+
}
|
|
228
|
+
}
|