whatsapp_notifier 0.8.0 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e65ad0d6844d9762bccd0a929a9d70613a0e9fb145564b75c35f196fc87e4088
4
- data.tar.gz: 109a415b4a618aac44d5ce9da4f886590a600e8bdb9168ada01e740e9b5add9f
3
+ metadata.gz: 130b5676184b753e6329f98ead313255db4a16be9393597f4b34f38741ae9c63
4
+ data.tar.gz: 7c7fe4a7eb0464676d4257443fa3003e80035e938e41d690aee7282787fd962a
5
5
  SHA512:
6
- metadata.gz: 45cbd20b37120dd2b533db5fa28b31dddbbbb00a06538c5a8c75cccd458d2f598c9d2089b898ed890b8cb88693613849d8ca036743d96bbba0d482cf121c70b4
7
- data.tar.gz: e63080fe514ff18d3f225baebeefef3856b10f83d80087c7c7a33e3b50cc5a150cdc4a964646520a5f866685c13dd08c14f0a8888bed93fc2df5403676eab93a
6
+ metadata.gz: 18e39291eabaa4a61e33a7a9f62a840b233320724147dc24614f740dafaa14151363a2e606943027098b42bed3dbea80db81459d1c73a38d1ad6989d0a1d2104
7
+ data.tar.gz: 35170c14888aa976a0eb211757e617a2237ab8e442ee75b9e319391d1737bbb75ce81d3a69802b71b9f712ab8d8731e4fd1d243123017cac05c5c686cac9830c
@@ -32,7 +32,7 @@ import {
32
32
  mediaGetResponse,
33
33
  mediaDeleteResponse
34
34
  } from './media';
35
- import { sentMessageId } from './send';
35
+ import { sentMessageId, sendValidationError, fetchMedia, captionOptions } from './send';
36
36
 
37
37
  const app = new Hono();
38
38
  const port = Number(process.env.PORT || 3001);
@@ -203,7 +203,7 @@ async function waitForClientReady(clientData: ClientData, timeoutMs = 30000): Pr
203
203
 
204
204
  // Resolves to the sent whatsapp-web.js Message so /send can hand the real
205
205
  // message id back to the host (the echo-dedupe key for two-way capture).
206
- async function sendMessageWithRetry(client: Client, clientData: ClientData, chatId: string, message: string, mediaUrl?: string | null) {
206
+ async function sendMessageWithRetry(client: Client, clientData: ClientData, chatId: string, message?: string | null, mediaUrl?: string | null) {
207
207
  const maxAttempts = 5;
208
208
 
209
209
  // Wait for the internal WWeb store to be fully loaded before first attempt
@@ -213,11 +213,12 @@ async function sendMessageWithRetry(client: Client, clientData: ClientData, chat
213
213
  try {
214
214
  if (mediaUrl) {
215
215
  const { MessageMedia } = require('whatsapp-web.js');
216
- const media = await MessageMedia.fromUrl(mediaUrl);
217
- return await client.sendMessage(chatId, media, { caption: message });
216
+ // unsafeMime + no-caption handling live in send.ts (unit-tested).
217
+ const media = await fetchMedia(MessageMedia, mediaUrl);
218
+ return await client.sendMessage(chatId, media, captionOptions(message));
218
219
  }
219
220
 
220
- return await client.sendMessage(chatId, message);
221
+ return await client.sendMessage(chatId, message!);
221
222
  } catch (error) {
222
223
  console.error(`Send attempt ${attempt}/${maxAttempts} failed for chat ${chatId}:`, error);
223
224
 
@@ -529,8 +530,10 @@ app.post('/logout/:userId', async (c) => {
529
530
  app.post('/send/:userId', async (c) => {
530
531
  const userId = c.req.param('userId');
531
532
  const { to, message, mediaUrl } = await c.req.json();
532
- if (!to || !message) {
533
- return c.json({ success: false, error: 'Both `to` and `message` are required' }, 422);
533
+ // `to` + something deliverable (message and/or mediaUrl) — see send.ts.
534
+ const validationError = sendValidationError({ to, message, mediaUrl });
535
+ if (validationError) {
536
+ return c.json({ success: false, error: validationError }, 422);
534
537
  }
535
538
  // Never spawn a client just to fail the auth check below: a send for a
536
539
  // never-paired user (e.g. a job defaulting to user "default") would
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'bun:test';
2
- import { sentMessageId } from './send';
2
+ import { sentMessageId, sendValidationError, fetchMedia, captionOptions } from './send';
3
3
 
4
4
  // The id the host stores against its outbound record — it MUST be the real
5
5
  // serialized WhatsApp id so the fromMe echo of this send dedupes on it.
@@ -18,3 +18,61 @@ test('sentMessageId falls back to null when no id is available', () => {
18
18
  expect(sentMessageId({ id: {} })).toBeNull(); // id without a serialization
19
19
  expect(sentMessageId({ id: { _serialized: '' } })).toBeNull(); // empty id is no id
20
20
  });
21
+
22
+ // ── /send body validation ──
23
+ //
24
+ // Hosts attach files one-by-one with the caption only on the FIRST file, so
25
+ // a file-only body (message "" + mediaUrl) is the NORMAL shape for files
26
+ // 2..n of a batch — it must pass, not 422.
27
+ test('file-only send (to + mediaUrl, no message) passes validation', () => {
28
+ expect(sendValidationError({ to: '919999000001', message: '', mediaUrl: 'https://host/blob/1' })).toBeNull();
29
+ expect(sendValidationError({ to: '919999000001', mediaUrl: 'https://host/blob/1' })).toBeNull();
30
+ });
31
+
32
+ test('message-only send still passes validation', () => {
33
+ expect(sendValidationError({ to: '919999000001', message: 'hello' })).toBeNull();
34
+ });
35
+
36
+ test('message + media together pass validation', () => {
37
+ expect(sendValidationError({ to: '919999000001', message: 'caption', mediaUrl: 'https://host/blob/1' })).toBeNull();
38
+ });
39
+
40
+ test('a body with nothing to deliver is rejected with 422 copy', () => {
41
+ const error = '`to` and one of `message`/`mediaUrl` are required';
42
+ expect(sendValidationError({ to: '919999000001' })).toBe(error);
43
+ expect(sendValidationError({ to: '919999000001', message: '', mediaUrl: '' })).toBe(error);
44
+ });
45
+
46
+ test('`to` is always required, media or not', () => {
47
+ const error = '`to` and one of `message`/`mediaUrl` are required';
48
+ expect(sendValidationError({ message: 'hello' })).toBe(error);
49
+ expect(sendValidationError({ to: '', message: 'hello', mediaUrl: 'https://host/blob/1' })).toBe(error);
50
+ });
51
+
52
+ // ── media fetch options ──
53
+ //
54
+ // ActiveStorage blob/proxy URLs carry no file extension, so wwebjs's
55
+ // URL-based MIME sniff throws ("Unable to determine MIME type using URL").
56
+ // The send path MUST pass unsafeMime so the response Content-Type is used.
57
+ test('fetchMedia downloads with unsafeMime so extension-less URLs work', async () => {
58
+ const calls: Array<[string, object | undefined]> = [];
59
+ const fakeMessageMedia = {
60
+ fromUrl: async (url: string, options?: object) => { calls.push([url, options]); return 'the-media'; }
61
+ };
62
+
63
+ const media = await fetchMedia(fakeMessageMedia, 'https://host/rails/active_storage/blobs/proxy/abc123');
64
+
65
+ expect(media).toBe('the-media');
66
+ expect(calls).toEqual([['https://host/rails/active_storage/blobs/proxy/abc123', { unsafeMime: true }]]);
67
+ });
68
+
69
+ // ── caption shape ──
70
+ test('captionOptions carries the caption when there is one', () => {
71
+ expect(captionOptions('itinerary attached')).toEqual({ caption: 'itinerary attached' });
72
+ });
73
+
74
+ test('captionOptions omits the caption entirely for caption-less files', () => {
75
+ expect(captionOptions('')).toEqual({});
76
+ expect(captionOptions(undefined)).toEqual({});
77
+ expect(captionOptions(null)).toEqual({});
78
+ });
@@ -15,3 +15,44 @@
15
15
  export function sentMessageId(sent: any): string | null {
16
16
  return (sent && sent.id && sent.id._serialized) || null;
17
17
  }
18
+
19
+ // Validation for the POST /send body. `to` is always required; the payload
20
+ // must additionally carry SOMETHING deliverable — a text message, a media
21
+ // URL, or both.
22
+ //
23
+ // The old `!to || !message` check 422'd every caption-less file: hosts that
24
+ // attach files one-by-one put the caption only on the FIRST file, so files
25
+ // 2..n of a batch (and any file sent without a caption) arrive as
26
+ // message "" + mediaUrl and were rejected.
27
+ //
28
+ // Returns the 422 error string, or null when the body is valid.
29
+ export function sendValidationError(body: { to?: unknown; message?: unknown; mediaUrl?: unknown }): string | null {
30
+ if (!body.to || (!body.message && !body.mediaUrl)) {
31
+ return '`to` and one of `message`/`mediaUrl` are required';
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // The whatsapp-web.js surface fetchMedia needs — injected so the fetch
37
+ // options are unit-testable without booting the real library. Promise<any>
38
+ // on purpose: the real MessageMedia comes from require() untyped, and the
39
+ // result feeds client.sendMessage's MessageContent parameter.
40
+ type MediaFactory = { fromUrl: (url: string, options?: object) => Promise<any> };
41
+
42
+ // Downloads the outgoing attachment for a media send.
43
+ //
44
+ // unsafeMime: hosts hand us extension-less URLs (Rails ActiveStorage
45
+ // blob/proxy paths), and whatsapp-web.js refuses to guess a MIME type from
46
+ // a URL without an extension ("Unable to determine MIME type using URL").
47
+ // unsafeMime downloads anyway and trusts the response Content-Type header,
48
+ // which those hosts set correctly.
49
+ export async function fetchMedia(messageMedia: MediaFactory, mediaUrl: string) {
50
+ return messageMedia.fromUrl(mediaUrl, { unsafeMime: true });
51
+ }
52
+
53
+ // sendMessage options for a media send: caption only when there IS one. A
54
+ // caption-less file arrives with message "" — omitting the key entirely
55
+ // matches a hand-sent file instead of attaching an empty caption.
56
+ export function captionOptions(message?: string | null): { caption?: string } {
57
+ return message ? { caption: message } : {};
58
+ }
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.8.0"
2
+ VERSION = "0.8.1"
3
3
 
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatsapp_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha