bloby-bot 0.26.1 → 0.27.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/package.json +4 -4
- package/supervisor/channels/manager.ts +73 -13
- package/supervisor/channels/whatsapp.ts +58 -0
- package/supervisor/index.ts +19 -5
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
|
-
"1.
|
|
6
|
-
"2. ",
|
|
7
|
-
"3. ",
|
|
5
|
+
"1. # voice note (PTT bubble)",
|
|
6
|
+
"2. # audio file + caption",
|
|
7
|
+
"3. # PDF",
|
|
8
8
|
"4. "
|
|
9
9
|
],
|
|
10
10
|
"description": "Self-hosted, self-evolving AI agent with its own dashboard.",
|
|
@@ -175,19 +175,9 @@ export class ChannelManager {
|
|
|
175
175
|
if (images.length > 0 && provider instanceof WhatsAppChannel) {
|
|
176
176
|
for (const img of images) {
|
|
177
177
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
path.join(WORKSPACE_DIR, 'files', relPath), // /api/files/images/x.png → workspace/files/images/x.png
|
|
182
|
-
path.join(WORKSPACE_DIR, relPath), // /x.png → workspace/x.png
|
|
183
|
-
path.join(WORKSPACE_DIR, 'client', 'public', relPath), // /x.png → workspace/client/public/x.png
|
|
184
|
-
];
|
|
185
|
-
const absPath = candidates.find((p) => fs.existsSync(p));
|
|
186
|
-
if (absPath) {
|
|
187
|
-
const buffer = fs.readFileSync(absPath);
|
|
188
|
-
const ext = path.extname(absPath).slice(1);
|
|
189
|
-
const mimeMap: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
190
|
-
await provider.sendImage(to, buffer, img.alt || undefined, mimeMap[ext] || 'image/png');
|
|
178
|
+
const resolved = this.resolveMediaFile(img.src);
|
|
179
|
+
if (resolved) {
|
|
180
|
+
await provider.sendImage(to, resolved.buffer, img.alt || undefined, resolved.mimetype);
|
|
191
181
|
} else {
|
|
192
182
|
log.warn(`[channels] Image file not found in any location: ${img.src}`);
|
|
193
183
|
}
|
|
@@ -198,6 +188,76 @@ export class ChannelManager {
|
|
|
198
188
|
}
|
|
199
189
|
}
|
|
200
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Send arbitrary media (audio, image, video, document) via a channel.
|
|
193
|
+
* Accepts a file path (absolute, workspace-relative, or /api/files/... URL).
|
|
194
|
+
*/
|
|
195
|
+
async sendMedia(
|
|
196
|
+
channel: ChannelType,
|
|
197
|
+
to: string,
|
|
198
|
+
media: { type: 'audio' | 'image' | 'video' | 'document'; path: string; mimetype?: string; fileName?: string; voiceNote?: boolean },
|
|
199
|
+
caption?: string,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const provider = this.providers.get(channel);
|
|
202
|
+
if (!provider) throw new Error(`Channel ${channel} not available`);
|
|
203
|
+
|
|
204
|
+
const resolved = this.resolveMediaFile(media.path);
|
|
205
|
+
if (!resolved) throw new Error(`Media file not found: ${media.path}`);
|
|
206
|
+
|
|
207
|
+
const mimetype = media.mimetype || resolved.mimetype;
|
|
208
|
+
|
|
209
|
+
if (!(provider instanceof WhatsAppChannel)) {
|
|
210
|
+
throw new Error(`Channel ${channel} does not support media`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
switch (media.type) {
|
|
214
|
+
case 'image':
|
|
215
|
+
await provider.sendImage(to, resolved.buffer, caption, mimetype);
|
|
216
|
+
break;
|
|
217
|
+
case 'audio':
|
|
218
|
+
await provider.sendAudio(to, resolved.buffer, { mimetype, voiceNote: media.voiceNote });
|
|
219
|
+
if (caption) await provider.sendMessage(to, caption);
|
|
220
|
+
break;
|
|
221
|
+
case 'video':
|
|
222
|
+
await provider.sendVideo(to, resolved.buffer, caption, mimetype);
|
|
223
|
+
break;
|
|
224
|
+
case 'document':
|
|
225
|
+
await provider.sendDocument(to, resolved.buffer, media.fileName || path.basename(resolved.absPath), mimetype, caption);
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
throw new Error(`Unsupported media type: ${(media as any).type}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Resolve a media path (absolute, /api/files/..., or workspace-relative) to buffer + inferred mimetype. */
|
|
233
|
+
private resolveMediaFile(src: string): { buffer: Buffer; mimetype: string; absPath: string } | null {
|
|
234
|
+
let absPath: string | undefined;
|
|
235
|
+
|
|
236
|
+
if (path.isAbsolute(src) && fs.existsSync(src)) {
|
|
237
|
+
absPath = src;
|
|
238
|
+
} else {
|
|
239
|
+
const relPath = src.replace(/^\/api\/files\//, '').replace(/^\/+/, '');
|
|
240
|
+
const candidates = [
|
|
241
|
+
path.join(WORKSPACE_DIR, 'files', relPath),
|
|
242
|
+
path.join(WORKSPACE_DIR, relPath),
|
|
243
|
+
path.join(WORKSPACE_DIR, 'client', 'public', relPath),
|
|
244
|
+
];
|
|
245
|
+
absPath = candidates.find((p) => fs.existsSync(p));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!absPath) return null;
|
|
249
|
+
|
|
250
|
+
const buffer = fs.readFileSync(absPath);
|
|
251
|
+
const ext = path.extname(absPath).slice(1).toLowerCase();
|
|
252
|
+
const mimeMap: Record<string, string> = {
|
|
253
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
|
|
254
|
+
mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg; codecs=opus', wav: 'audio/wav',
|
|
255
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
256
|
+
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
|
|
257
|
+
};
|
|
258
|
+
return { buffer, mimetype: mimeMap[ext] || 'application/octet-stream', absPath };
|
|
259
|
+
}
|
|
260
|
+
|
|
201
261
|
/** Show "typing..." indicator in a chat */
|
|
202
262
|
startTyping(channel: ChannelType, jid: string): void {
|
|
203
263
|
const provider = this.providers.get(channel);
|
|
@@ -131,6 +131,64 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
131
131
|
log.info(`[whatsapp] Sent image to ${jid} (id=${result?.key?.id || 'unknown'})`);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** Send an audio file via WhatsApp. Set voiceNote=true for push-to-talk bubble. */
|
|
135
|
+
async sendAudio(to: string, audio: Buffer, opts?: { mimetype?: string; voiceNote?: boolean }): Promise<void> {
|
|
136
|
+
if (!this.sock || !this.connected) {
|
|
137
|
+
log.warn('[whatsapp] Cannot send audio — not connected');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
|
|
141
|
+
this.stopTyping(jid);
|
|
142
|
+
|
|
143
|
+
const msg: any = {
|
|
144
|
+
audio,
|
|
145
|
+
mimetype: opts?.mimetype || 'audio/mpeg',
|
|
146
|
+
ptt: !!opts?.voiceNote,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = await this.sock.sendMessage(jid, msg);
|
|
150
|
+
if (result?.key?.id) this.trackSentId(result.key.id);
|
|
151
|
+
log.info(`[whatsapp] Sent audio to ${jid} (ptt=${msg.ptt}, mime=${msg.mimetype}, id=${result?.key?.id || 'unknown'})`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Send a video via WhatsApp */
|
|
155
|
+
async sendVideo(to: string, video: Buffer, caption?: string, mimetype?: string): Promise<void> {
|
|
156
|
+
if (!this.sock || !this.connected) {
|
|
157
|
+
log.warn('[whatsapp] Cannot send video — not connected');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
|
|
161
|
+
this.stopTyping(jid);
|
|
162
|
+
|
|
163
|
+
const msg: any = { video, mimetype: mimetype || 'video/mp4' };
|
|
164
|
+
if (caption) msg.caption = caption;
|
|
165
|
+
|
|
166
|
+
const result = await this.sock.sendMessage(jid, msg);
|
|
167
|
+
if (result?.key?.id) this.trackSentId(result.key.id);
|
|
168
|
+
log.info(`[whatsapp] Sent video to ${jid} (id=${result?.key?.id || 'unknown'})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Send a document (PDF, zip, etc.) via WhatsApp */
|
|
172
|
+
async sendDocument(to: string, document: Buffer, fileName: string, mimetype?: string, caption?: string): Promise<void> {
|
|
173
|
+
if (!this.sock || !this.connected) {
|
|
174
|
+
log.warn('[whatsapp] Cannot send document — not connected');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
|
|
178
|
+
this.stopTyping(jid);
|
|
179
|
+
|
|
180
|
+
const msg: any = {
|
|
181
|
+
document,
|
|
182
|
+
mimetype: mimetype || 'application/octet-stream',
|
|
183
|
+
fileName,
|
|
184
|
+
};
|
|
185
|
+
if (caption) msg.caption = caption;
|
|
186
|
+
|
|
187
|
+
const result = await this.sock.sendMessage(jid, msg);
|
|
188
|
+
if (result?.key?.id) this.trackSentId(result.key.id);
|
|
189
|
+
log.info(`[whatsapp] Sent document to ${jid} (name=${fileName}, id=${result?.key?.id || 'unknown'})`);
|
|
190
|
+
}
|
|
191
|
+
|
|
134
192
|
/** Show "typing..." indicator in a chat. Re-sends every 20s to keep it visible. */
|
|
135
193
|
startTyping(jid: string): void {
|
|
136
194
|
if (!this.sock || !this.connected) return;
|
package/supervisor/index.ts
CHANGED
|
@@ -734,19 +734,33 @@ ${!connected ? `<script>
|
|
|
734
734
|
return;
|
|
735
735
|
}
|
|
736
736
|
|
|
737
|
-
// POST /api/channels/send — send a message via any channel
|
|
737
|
+
// POST /api/channels/send — send a message (and/or media) via any channel
|
|
738
738
|
if (req.method === 'POST' && channelPath === '/api/channels/send') {
|
|
739
739
|
let body = '';
|
|
740
740
|
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
741
741
|
req.on('end', async () => {
|
|
742
742
|
try {
|
|
743
|
-
const { channel, to, text } = JSON.parse(body);
|
|
744
|
-
if (!channel || !to
|
|
743
|
+
const { channel, to, text, media } = JSON.parse(body);
|
|
744
|
+
if (!channel || !to) {
|
|
745
745
|
res.writeHead(400);
|
|
746
|
-
res.end(JSON.stringify({ error: 'Missing channel
|
|
746
|
+
res.end(JSON.stringify({ error: 'Missing channel or to' }));
|
|
747
747
|
return;
|
|
748
748
|
}
|
|
749
|
-
|
|
749
|
+
if (!text && !media) {
|
|
750
|
+
res.writeHead(400);
|
|
751
|
+
res.end(JSON.stringify({ error: 'Missing text or media' }));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (media) {
|
|
755
|
+
if (!media.type || !media.path) {
|
|
756
|
+
res.writeHead(400);
|
|
757
|
+
res.end(JSON.stringify({ error: 'media requires { type, path }' }));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
await channelManager.sendMedia(channel, to, media, text);
|
|
761
|
+
} else {
|
|
762
|
+
await channelManager.sendMessage(channel, to, text);
|
|
763
|
+
}
|
|
750
764
|
res.writeHead(200);
|
|
751
765
|
res.end(JSON.stringify({ ok: true }));
|
|
752
766
|
} catch (err: any) {
|