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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "releaseNotes": [
5
- "1. new stuff",
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
- // Resolve file path — try multiple locations since the bot might save anywhere
179
- const relPath = img.src.replace(/^\/api\/files\//, '');
180
- const candidates = [
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;
@@ -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 || !text) {
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, to, or text' }));
746
+ res.end(JSON.stringify({ error: 'Missing channel or to' }));
747
747
  return;
748
748
  }
749
- await channelManager.sendMessage(channel, to, text);
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) {