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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 130b5676184b753e6329f98ead313255db4a16be9393597f4b34f38741ae9c63
|
|
4
|
+
data.tar.gz: 7c7fe4a7eb0464676d4257443fa3003e80035e938e41d690aee7282787fd962a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
+
}
|