whatsapp_notifier 0.4.0 → 0.5.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: '0202159fbe717a98a4bb69187abf75876258ce5f1d0bb1840deb46ed83096002'
4
- data.tar.gz: df8857673fc57f8be6ba9681eb959d9aa842a051a522ceed530a19e57868d1b3
3
+ metadata.gz: fc24f1c67db14b3be6dfc49310e7a86987c520c6ddc595895d6e00b483aafab4
4
+ data.tar.gz: e600799c44eeefddfa3476c65894bc1aa430d7c488685e7172c9dcaf5e9c27df
5
5
  SHA512:
6
- metadata.gz: a9618573ca58faba4ba1b98c69626caf7627288a1b490ea102b39053166d15552d83a30e7a2583db5702111a312cd33b6bdb7dc02c011d2f355161259532cc9a
7
- data.tar.gz: 973ff970420fc87db0280f82758f2ba51cf6cc54189d0b1ea0a2ba84f43aaa993ec4938b6a606ebae0cb14bc38e314c832b6c56fb1622712433a64fb0eaa7c33
6
+ metadata.gz: f85d85f6e467055c93c8c5ba30c325a8d68f1113b5d257d3b26f8397976706fb771cff63cedadba721f50c87d24f5159a79fac570ce6e1b2a2cffe84be877b30
7
+ data.tar.gz: 747e11eeee1780b222f7e46c044dad8bd8235462dadc5b3f8f0afb2d4d2f04a84c7e71ece2bfbcbab4571a061cda6cfd2671e0e736dbe7a932d4b81d86101df4
@@ -30,5 +30,18 @@ module WhatsAppNotifier
30
30
  render json: { success: false, error: e.message },
31
31
  status: :internal_server_error
32
32
  end
33
+
34
+ # GET /inbound — drain pending inbound messages for the current user.
35
+ # Optional convenience for host apps that prefer pulling through Rails
36
+ # instead of calling WhatsAppNotifier.fetch_inbound directly.
37
+ def inbound
38
+ messages = WhatsAppNotifier.fetch_inbound(
39
+ metadata: { user_id: whatsapp_notifier_user_id }
40
+ )
41
+ render json: { messages: messages }
42
+ rescue StandardError => e
43
+ render json: { success: false, error: e.message },
44
+ status: :internal_server_error
45
+ end
33
46
  end
34
47
  end
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  WhatsAppNotifier::Engine.routes.draw do
2
- get "status", to: "sessions#show", as: :status
3
- get "qr", to: "sessions#qr", as: :qr
4
- delete "logout", to: "sessions#destroy", as: :logout
5
- post "send", to: "messages#create", as: :send_message
2
+ get "status", to: "sessions#show", as: :status
3
+ get "qr", to: "sessions#qr", as: :qr
4
+ delete "logout", to: "sessions#destroy", as: :logout
5
+ post "send", to: "messages#create", as: :send_message
6
+ get "inbound", to: "messages#inbound", as: :inbound
6
7
  end
@@ -27,6 +27,10 @@ module WhatsAppNotifier
27
27
  provider_for(provider || @configuration.provider).connection_status(metadata: metadata)
28
28
  end
29
29
 
30
+ def fetch_inbound(metadata: {}, provider: nil)
31
+ provider_for(provider || @configuration.provider).fetch_inbound(metadata: metadata)
32
+ end
33
+
30
34
  def logout(metadata: {}, provider: nil)
31
35
  provider_for(provider || @configuration.provider).logout(metadata: metadata)
32
36
  end
@@ -7,7 +7,7 @@ module WhatsAppNotifier
7
7
  :bulk_max_attempts, :bulk_retryable_error_codes, :logger,
8
8
  :web_automation_enabled, :warn_on_risky_provider,
9
9
  :authenticate_with, :current_user_id_resolver,
10
- :parent_controller
10
+ :parent_controller, :on_inbound_message_handler
11
11
 
12
12
  def initialize
13
13
  @provider = :web_automation
@@ -24,6 +24,9 @@ module WhatsAppNotifier
24
24
  @authenticate_with = nil
25
25
  @current_user_id_resolver = -> { respond_to?(:current_user) && current_user ? current_user.id : nil }
26
26
  @parent_controller = "::ApplicationController"
27
+ # Optional host hook: a callable invoked with each inbound message hash.
28
+ # Lets a push-based integration react without polling. Nil = no handler.
29
+ @on_inbound_message_handler = nil
27
30
  end
28
31
 
29
32
  def validate!
@@ -48,6 +48,19 @@ module WhatsAppNotifier
48
48
  adapter.connection_status(metadata: metadata)
49
49
  end
50
50
 
51
+ # Pull pending inbound messages for the user. fetch_inbound is an
52
+ # optional adapter capability (added in v0.4.0) — older adapters that
53
+ # predate inbound support raise a clear ConfigurationError rather than
54
+ # NoMethodError, and Configuration#validate! does not require it.
55
+ def fetch_inbound(metadata: {})
56
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
57
+
58
+ adapter = configuration.web_adapter
59
+ raise ConfigurationError, "web_adapter does not support inbound capture (upgrade to a fetch_inbound-capable adapter)" unless adapter.respond_to?(:fetch_inbound)
60
+
61
+ adapter.fetch_inbound(metadata: metadata)
62
+ end
63
+
51
64
  def logout(metadata: {})
52
65
  raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
53
66
 
@@ -0,0 +1,106 @@
1
+ import { test, expect, beforeEach, afterAll } from 'bun:test';
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import {
6
+ INBOUND_QUEUE_CAP,
7
+ configureInbound,
8
+ loadTargets,
9
+ rememberTarget,
10
+ enqueueInbound,
11
+ drainInbound,
12
+ shouldCapture,
13
+ normalizeInbound,
14
+ resetInboundState
15
+ } from './inbound';
16
+
17
+ const root = mkdtempSync(join(tmpdir(), 'wa-inbound-'));
18
+ const dirFor = (userId: string) => join(root, `session-user-${userId}`);
19
+
20
+ beforeEach(() => {
21
+ resetInboundState();
22
+ configureInbound(dirFor);
23
+ });
24
+
25
+ afterAll(() => {
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ const CUST = '919999000001@c.us';
30
+
31
+ function msg(overrides: any = {}) {
32
+ return {
33
+ from: CUST,
34
+ body: 'hello',
35
+ fromMe: false,
36
+ isStatus: false,
37
+ id: { _serialized: 'true_919999000001@c.us_ABC' },
38
+ timestamp: 1717000000,
39
+ type: 'chat',
40
+ ...overrides
41
+ };
42
+ }
43
+
44
+ // G10 — sanity filter (captures any real inbound 1:1; host matches relevance)
45
+ test('shouldCapture: any inbound 1:1 chat, no allowlist gate', () => {
46
+ expect(shouldCapture('1', msg())).toBe(true); // @c.us 1:1
47
+ expect(shouldCapture('1', msg({ from: '125417440686124@lid' }))).toBe(true); // @lid 1:1
48
+
49
+ expect(shouldCapture('1', msg({ type: 'image' }))).toBe(true); // media is real content
50
+
51
+ expect(shouldCapture('1', msg({ fromMe: true }))).toBe(false); // own message
52
+ expect(shouldCapture('1', msg({ from: '12@g.us' }))).toBe(false); // group
53
+ expect(shouldCapture('1', msg({ isStatus: true }))).toBe(false); // status
54
+ expect(shouldCapture('1', msg({ from: 'status@broadcast' }))).toBe(false);
55
+ expect(shouldCapture('1', msg({ type: 'e2e_notification' }))).toBe(false); // system event
56
+ expect(shouldCapture('1', msg({ type: 'call_log' }))).toBe(false); // system event
57
+ expect(shouldCapture('1', null)).toBe(false); // junk
58
+ });
59
+
60
+ // allowlist persists to disk + reloads
61
+ test('rememberTarget persists and loadTargets reloads from disk', () => {
62
+ rememberTarget('1', CUST);
63
+ const file = join(dirFor('1'), 'outbound_targets.json');
64
+ expect(existsSync(file)).toBe(true);
65
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual([CUST]);
66
+
67
+ resetInboundState(); // drop in-memory cache → must reload from file
68
+ expect(loadTargets('1').has(CUST)).toBe(true);
69
+ });
70
+
71
+ // G11 — enqueue + drain
72
+ test('enqueue then drain returns once, then empties', () => {
73
+ enqueueInbound('1', normalizeInbound(msg()));
74
+ enqueueInbound('1', normalizeInbound(msg({ id: { _serialized: 'm2' } })));
75
+
76
+ const first = drainInbound('1');
77
+ expect(first.length).toBe(2);
78
+
79
+ const second = drainInbound('1');
80
+ expect(second.length).toBe(0);
81
+ });
82
+
83
+ // G12 — queue cap (drop oldest)
84
+ test('queue is bounded to INBOUND_QUEUE_CAP (drops oldest)', () => {
85
+ for (let i = 0; i < INBOUND_QUEUE_CAP + 5; i++) {
86
+ enqueueInbound('1', normalizeInbound(msg({ id: { _serialized: `m${i}` }, body: String(i) })));
87
+ }
88
+ const drained = drainInbound('1');
89
+ expect(drained.length).toBe(INBOUND_QUEUE_CAP);
90
+ expect(drained[0].body).toBe('5'); // 0-4 dropped
91
+ expect(drained[drained.length - 1].body).toBe(String(INBOUND_QUEUE_CAP + 4));
92
+ });
93
+
94
+ // normalize shape + messageId fallback
95
+ test('normalizeInbound maps fields and falls back on missing id', () => {
96
+ const a = normalizeInbound(msg());
97
+ expect(a).toEqual({
98
+ from: CUST, body: 'hello', messageId: 'true_919999000001@c.us_ABC',
99
+ timestamp: 1717000000, type: 'chat'
100
+ });
101
+
102
+ const b = normalizeInbound({ from: CUST, timestamp: 42 });
103
+ expect(b.messageId).toBe(`${CUST}-42`); // fallback id
104
+ expect(b.body).toBe('');
105
+ expect(b.type).toBe('chat');
106
+ });
@@ -0,0 +1,119 @@
1
+ // Inbound capture core (v0.4.0)
2
+ //
3
+ // Pure, testable logic for the two-way layer. Kept separate from index.ts so
4
+ // it can be unit-tested without booting a whatsapp-web.js Client.
5
+ //
6
+ // Policy: surface real inbound 1:1 messages (phone @c.us or privacy-id @lid)
7
+ // — dropping our own messages, groups (@g.us), status broadcasts, and non-text
8
+ // system events (e2e_notification, call_log, revoked, …) that carry no real
9
+ // reply. Captured messages buffer in an in-memory queue drained by GET
10
+ // /inbound/:userId (at-least-once; the host dedupes on messageId and decides
11
+ // relevance by matching the resolved phone to its own recipient records).
12
+
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
+ import { join } from 'path';
15
+
16
+ export interface InboundMsg {
17
+ from: string;
18
+ body: string;
19
+ messageId: string;
20
+ timestamp: number;
21
+ type: string;
22
+ }
23
+
24
+ export const INBOUND_QUEUE_CAP = 1000;
25
+
26
+ const inboundQueues = new Map<string, InboundMsg[]>();
27
+ const outboundTargets = new Map<string, Set<string>>();
28
+
29
+ // How to resolve a user's on-disk session dir. index.ts wires this to
30
+ // sessionDirForUser; tests point it at a tmp dir.
31
+ let baseDirResolver: (userId: string) => string = () => '.';
32
+ export function configureInbound(resolver: (userId: string) => string) {
33
+ baseDirResolver = resolver;
34
+ }
35
+
36
+ function targetsFilePath(userId: string) {
37
+ return join(baseDirResolver(userId), 'outbound_targets.json');
38
+ }
39
+
40
+ export function loadTargets(userId: string): Set<string> {
41
+ const cached = outboundTargets.get(userId);
42
+ if (cached) return cached;
43
+
44
+ let set = new Set<string>();
45
+ try {
46
+ const p = targetsFilePath(userId);
47
+ if (existsSync(p)) {
48
+ const arr = JSON.parse(readFileSync(p, 'utf8'));
49
+ if (Array.isArray(arr)) set = new Set(arr);
50
+ }
51
+ } catch (_) { /* corrupt/missing file → start empty */ }
52
+ outboundTargets.set(userId, set);
53
+ return set;
54
+ }
55
+
56
+ export function rememberTarget(userId: string, chatId: string) {
57
+ const set = loadTargets(userId);
58
+ if (set.has(chatId)) return;
59
+ set.add(chatId);
60
+ try {
61
+ const dir = baseDirResolver(userId);
62
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
+ writeFileSync(targetsFilePath(userId), JSON.stringify([...set]));
64
+ } catch (e) {
65
+ console.error(`Failed to persist outbound target for ${userId}`, e);
66
+ }
67
+ }
68
+
69
+ export function enqueueInbound(userId: string, msg: InboundMsg) {
70
+ let q = inboundQueues.get(userId);
71
+ if (!q) { q = []; inboundQueues.set(userId, q); }
72
+ q.push(msg);
73
+ // Bound memory: drop oldest beyond the cap.
74
+ if (q.length > INBOUND_QUEUE_CAP) q.splice(0, q.length - INBOUND_QUEUE_CAP);
75
+ }
76
+
77
+ export function drainInbound(userId: string): InboundMsg[] {
78
+ const q = inboundQueues.get(userId) || [];
79
+ inboundQueues.set(userId, []);
80
+ return q;
81
+ }
82
+
83
+ // Sanity filter for a real inbound 1:1 reply. Accepts both phone-number chats
84
+ // (@c.us) and privacy-id chats (@lid — newer WhatsApp delivers replies from an
85
+ // @lid). Drops own messages, groups (@g.us) and status (@broadcast). The host
86
+ // app decides relevance by matching the resolved phone to its own records, so
87
+ // we no longer gate on the per-send allowlist (which was unreliable).
88
+ // Real human/content message types. Anything else (e2e_notification,
89
+ // notification_template, call_log, revoked, protocol, gp2, …) is a system event
90
+ // with no body and must NOT be surfaced as a reply.
91
+ const TEXTUAL_TYPES = new Set([
92
+ 'chat', 'image', 'video', 'audio', 'ptt', 'document', 'sticker', 'location', 'vcard'
93
+ ]);
94
+
95
+ export function shouldCapture(userId: string, msg: any): boolean {
96
+ if (!msg || msg.fromMe) return false;
97
+ const from: string = msg.from || '';
98
+ if (!from.endsWith('@c.us') && !from.endsWith('@lid')) return false;
99
+ if (msg.isStatus) return false;
100
+ if (msg.type && !TEXTUAL_TYPES.has(msg.type)) return false; // drop system events
101
+ return true;
102
+ }
103
+
104
+ export function normalizeInbound(msg: any): InboundMsg {
105
+ const from: string = msg.from || '';
106
+ return {
107
+ from,
108
+ body: msg.body || '',
109
+ messageId: (msg.id && msg.id._serialized) || `${from}-${msg.timestamp}`,
110
+ timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
111
+ type: msg.type || 'chat'
112
+ };
113
+ }
114
+
115
+ // Test helper: wipe in-memory state between examples.
116
+ export function resetInboundState() {
117
+ inboundQueues.clear();
118
+ outboundTargets.clear();
119
+ }
@@ -3,6 +3,16 @@ import { Hono } from 'hono';
3
3
  import { rmSync, existsSync, readdirSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { toDataURL } from 'qrcode';
6
+ import {
7
+ InboundMsg,
8
+ configureInbound,
9
+ loadTargets,
10
+ rememberTarget,
11
+ enqueueInbound,
12
+ drainInbound,
13
+ shouldCapture,
14
+ normalizeInbound
15
+ } from './inbound';
6
16
 
7
17
  const app = new Hono();
8
18
  const port = Number(process.env.PORT || 3001);
@@ -34,6 +44,72 @@ interface ClientData {
34
44
  const clients = new Map<string, ClientData>();
35
45
  const initializingClients = new Set<string>();
36
46
 
47
+ // ── Inbound capture (v0.4.0) — core logic lives in ./inbound.ts ──
48
+ const WEBHOOK_URL = process.env.WHATSAPP_WEBHOOK_URL;
49
+ const WEBHOOK_TOKEN = process.env.WHATSAPP_WEBHOOK_TOKEN;
50
+
51
+ // Tell the inbound core how to resolve each user's on-disk session dir.
52
+ configureInbound(sessionDirForUser);
53
+
54
+ async function pushWebhook(userId: string, msg: InboundMsg) {
55
+ if (!WEBHOOK_URL) return;
56
+ try {
57
+ await fetch(WEBHOOK_URL, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ ...(WEBHOOK_TOKEN ? { 'X-WA-Token': WEBHOOK_TOKEN } : {})
62
+ },
63
+ body: JSON.stringify({ userId, message: msg })
64
+ });
65
+ } catch (e) {
66
+ console.error(`Webhook push failed for ${userId}`, e);
67
+ }
68
+ }
69
+
70
+ // Wrapper: sanity filter → normalize → resolve @lid → enqueue → optional webhook.
71
+ async function captureInbound(userId: string, msg: any) {
72
+ try {
73
+ if (!shouldCapture(userId, msg)) return;
74
+ const inbound = normalizeInbound(msg);
75
+ // Newer WhatsApp delivers the reply's `from` as an @lid privacy id with
76
+ // no phone number, which the host can't match. Resolve it to the real
77
+ // phone via the contact so callers always get a phone-number @c.us.
78
+ if (inbound.from.endsWith('@lid')) {
79
+ try {
80
+ const contact = await msg.getContact();
81
+ const num = contact && (contact.number || (contact.id && contact.id.user));
82
+ if (num) inbound.from = `${String(num).replace(/\D/g, '')}@c.us`;
83
+ } catch (e) {
84
+ console.error(`lid->phone resolve failed for ${userId}`, e);
85
+ }
86
+ // Still an @lid => no phone to match or scope by. Drop it rather than
87
+ // forward an unmatchable, unpurgeable plaintext body.
88
+ if (inbound.from.endsWith('@lid')) return;
89
+ }
90
+ enqueueInbound(userId, inbound);
91
+ pushWebhook(userId, inbound);
92
+ } catch (e) {
93
+ console.error(`captureInbound error for ${userId}`, e);
94
+ }
95
+ }
96
+
97
+ async function backfillInbound(userId: string, client: Client) {
98
+ // On reconnect, replay recent messages ONLY from chats we actually messaged
99
+ // (the per-send allowlist) so a disconnect window doesn't drop a reply —
100
+ // without scraping every personal conversation on the linked number. Live
101
+ // replies are covered by the message_create handler; this is just recovery.
102
+ const targets = loadTargets(userId);
103
+ if (targets.size === 0) return;
104
+ for (const chatId of targets) {
105
+ try {
106
+ const chat = await client.getChatById(chatId);
107
+ const msgs = await chat.fetchMessages({ limit: 20 });
108
+ for (const m of msgs) captureInbound(userId, m);
109
+ } catch (_) { /* chat not materialized yet → skip */ }
110
+ }
111
+ }
112
+
37
113
  function clearInitTimer(clientData: ClientData) {
38
114
  if (clientData.initTimer) {
39
115
  clearTimeout(clientData.initTimer);
@@ -197,8 +273,21 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
197
273
  clientData.ready = true;
198
274
  clearInitTimer(clientData);
199
275
  console.log(`Client is READY for User ${userId}`);
276
+ // Replay anything that arrived while we were disconnected.
277
+ backfillInbound(userId, client).catch((e) => console.error(`Backfill failed for ${userId}`, e));
200
278
  });
201
279
 
280
+ // Capture inbound replies. 'message' fires for incoming only, but in some
281
+ // linked/multi-device sessions it silently never fires — 'message_create'
282
+ // is the reliable event (fires for all messages). shouldCapture drops our
283
+ // own sends (fromMe) + groups/status, and the queue dedupes on message_id,
284
+ // so listening on both is safe.
285
+ // Only 'message_create' — it fires reliably for every message across linked/
286
+ // multi-device sessions (plain 'message' silently never fires on some), and
287
+ // listening on both would double-enqueue every reply. shouldCapture drops
288
+ // our own sends (fromMe).
289
+ client.on('message_create', (msg) => captureInbound(userId, msg));
290
+
202
291
  client.on('authenticated', () => {
203
292
  clientData.state = 'AUTHENTICATED';
204
293
  clientData.ready = false;
@@ -326,6 +415,9 @@ app.post('/send/:userId', async (c) => {
326
415
  const chatId = to.includes('@c.us') ? to : `${to}@c.us`;
327
416
  await sendMessageWithRetry(data.client, data, chatId, message, mediaUrl);
328
417
 
418
+ // Record the recipient so their replies pass the inbound allowlist.
419
+ rememberTarget(userId, chatId);
420
+
329
421
  data.lastUsed = Date.now();
330
422
  return c.json({ success: true });
331
423
  } catch (error: any) {
@@ -334,6 +426,15 @@ app.post('/send/:userId', async (c) => {
334
426
  }
335
427
  });
336
428
 
429
+ // GET /inbound/:userId — drain the user's pending inbound queue.
430
+ // Returns { messages: [...] } and clears the buffer (at-least-once;
431
+ // the Rails side dedupes on messageId).
432
+ app.get('/inbound/:userId', async (c) => {
433
+ const userId = c.req.param('userId');
434
+ await getOrCreateClient(userId);
435
+ return c.json({ messages: drainInbound(userId) });
436
+ });
437
+
337
438
 
338
439
  console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
339
440
 
@@ -2,6 +2,9 @@
2
2
  "name": "whatsapp-service",
3
3
  "version": "1.0.0",
4
4
  "main": "index.ts",
5
+ "scripts": {
6
+ "test": "bun test"
7
+ },
5
8
  "dependencies": {
6
9
  "hono": "^4.12.9",
7
10
  "whatsapp-web.js": "1.34.6",
@@ -9,13 +9,11 @@ module WhatsAppNotifier
9
9
  end
10
10
 
11
11
  def qr_code(metadata: {})
12
- generated = adapter.fetch_qr_code(metadata: metadata)
13
- # We don't cache the QR code because it expires every ~20 seconds
14
- # The underlying adapter/service is responsible for providing the latest one
15
- generated
12
+ # We don't cache the QR code because it expires every ~20 seconds.
13
+ # The underlying adapter/service provides the latest one each call.
14
+ adapter.fetch_qr_code(metadata: metadata)
16
15
  end
17
16
 
18
-
19
17
  def activate!(token)
20
18
  session = store.load
21
19
  store.save(session.merge(active: true, token: token, qr_code: nil))
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.1"
3
3
 
4
4
  end
@@ -52,6 +52,25 @@ module WhatsAppNotifier
52
52
  }
53
53
  end
54
54
 
55
+ # Drains the service's pending inbound queue for this user. The service
56
+ # returns the messages once, then clears them (at-least-once handoff —
57
+ # callers must dedupe on message_id). Accepts either a bare array or a
58
+ # { "messages" => [...] } envelope so the wire format can evolve.
59
+ def fetch_inbound(metadata: {})
60
+ user_id = user_id_from(metadata)
61
+ response = request(:get, "/inbound/#{user_id}")
62
+ raw = response.is_a?(Hash) ? response["messages"] : response
63
+ Array(raw).map do |m|
64
+ {
65
+ from: m["from"],
66
+ body: m["body"],
67
+ message_id: m["messageId"] || m["message_id"],
68
+ timestamp: m["timestamp"],
69
+ type: m["type"]
70
+ }
71
+ end
72
+ end
73
+
55
74
  # Logs the user out of WhatsApp and clears their saved session on the service.
56
75
  def logout(metadata: {})
57
76
  user_id = user_id_from(metadata)
@@ -62,6 +62,10 @@ module WhatsAppNotifier
62
62
  client.connection_status(provider: provider, metadata: metadata)
63
63
  end
64
64
 
65
+ def fetch_inbound(provider: nil, metadata: {})
66
+ client.fetch_inbound(provider: provider, metadata: metadata)
67
+ end
68
+
65
69
  def logout(provider: nil, metadata: {})
66
70
  client.logout(provider: provider, metadata: metadata)
67
71
  end
data/spec/client_spec.rb CHANGED
@@ -50,6 +50,23 @@ RSpec.describe WhatsAppNotifier::Client do
50
50
  end
51
51
  end
52
52
 
53
+ it "delegates fetch_inbound to the provider" do
54
+ Dir.mktmpdir do |dir|
55
+ config.provider = :web_automation
56
+ config.web_automation_enabled = true
57
+ config.web_session_path = File.join(dir, "session.json")
58
+ config.web_adapter = double(
59
+ send_message: { success: true, session: {} },
60
+ fetch_qr_code: "qr",
61
+ connection_status: { state: "AUTHENTICATED", authenticated: true },
62
+ fetch_inbound: [{ from: "a@c.us", body: "hi" }]
63
+ )
64
+ client = described_class.new(configuration: config)
65
+
66
+ expect(client.fetch_inbound(provider: :web_automation)).to eq([{ from: "a@c.us", body: "hi" }])
67
+ end
68
+ end
69
+
53
70
  it "delegates logout to the provider" do
54
71
  Dir.mktmpdir do |dir|
55
72
  config.provider = :web_automation
@@ -44,4 +44,20 @@ RSpec.describe WhatsAppNotifier::Configuration do
44
44
 
45
45
  expect { config.validate! }.to raise_error(WhatsAppNotifier::ConfigurationError, /web_adapter must be configured/)
46
46
  end
47
+
48
+ it "exposes an inbound message handler accessor that defaults to nil" do
49
+ config = described_class.new
50
+ expect(config.on_inbound_message_handler).to be_nil
51
+
52
+ handler = ->(msg) { msg }
53
+ config.on_inbound_message_handler = handler
54
+ expect(config.on_inbound_message_handler).to eq(handler)
55
+ end
56
+
57
+ it "validates without requiring fetch_inbound on the adapter (inbound is optional)" do
58
+ config = described_class.new
59
+ config.web_adapter = double(send_message: {}, fetch_qr_code: "qr", connection_status: {})
60
+
61
+ expect { config.validate! }.not_to raise_error
62
+ end
47
63
  end
@@ -107,6 +107,36 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
107
107
  end
108
108
  end
109
109
 
110
+ it "fetches inbound via the adapter when enabled" do
111
+ Dir.mktmpdir do |dir|
112
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_inbound: [{ from: "x@c.us", body: "hi" }])
113
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
114
+ provider = described_class.new(configuration: config)
115
+
116
+ expect(provider.fetch_inbound(metadata: { user_id: 1 })).to eq([{ from: "x@c.us", body: "hi" }])
117
+ end
118
+ end
119
+
120
+ it "raises on fetch_inbound when the provider is disabled" do
121
+ Dir.mktmpdir do |dir|
122
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_inbound: [])
123
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
124
+ provider = described_class.new(configuration: config)
125
+
126
+ expect { provider.fetch_inbound }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
127
+ end
128
+ end
129
+
130
+ it "raises on fetch_inbound when the adapter lacks inbound support" do
131
+ Dir.mktmpdir do |dir|
132
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
133
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
134
+ provider = described_class.new(configuration: config)
135
+
136
+ expect { provider.fetch_inbound }.to raise_error(WhatsAppNotifier::ConfigurationError, /inbound capture/)
137
+ end
138
+ end
139
+
110
140
  it "logs out via adapter when enabled" do
111
141
  Dir.mktmpdir do |dir|
112
142
  adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
@@ -24,6 +24,20 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
24
24
  expect(result).to include(success: true, message_id: "k1")
25
25
  end
26
26
 
27
+ it "yields the http connection to run the request" do
28
+ response = http_success(body: { "success" => true })
29
+ http = instance_double(Net::HTTP, request: response)
30
+ allow(Net::HTTP).to receive(:start).and_yield(http).and_return(response)
31
+
32
+ result = adapter.send_message(
33
+ payload: { to: "+1", body: "hi", metadata: {}, idempotency_key: "k1" },
34
+ session: {}
35
+ )
36
+
37
+ expect(result).to include(success: true)
38
+ expect(http).to have_received(:request)
39
+ end
40
+
27
41
  it "fetches qr and status data" do
28
42
  qr_response = http_success(body: { "qr" => "data:image/png;base64,qr" })
29
43
  status_response = http_success(body: { "state" => "AUTHENTICATED", "authenticated" => true, "hasQR" => false })
@@ -53,6 +67,39 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
53
67
  expect { adapter.connection_status(metadata: {}) }.to raise_error(/raw-error/)
54
68
  end
55
69
 
70
+ it "fetches inbound messages from a bare array body" do
71
+ body = [
72
+ { "from" => "919@c.us", "body" => "hi", "messageId" => "m1", "timestamp" => 123, "type" => "chat" }
73
+ ]
74
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
75
+
76
+ messages = adapter.fetch_inbound(metadata: { user_id: "u-1" })
77
+
78
+ expect(messages).to eq([{ from: "919@c.us", body: "hi", message_id: "m1", timestamp: 123, type: "chat" }])
79
+ end
80
+
81
+ it "fetches inbound from a {messages:} envelope and accepts the message_id alias" do
82
+ body = { "messages" => [{ "from" => "918@c.us", "body" => "yo", "message_id" => "m2", "timestamp" => 9 }] }
83
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
84
+
85
+ messages = adapter.fetch_inbound(metadata: { user_id: "u-1" })
86
+
87
+ expect(messages.first).to include(from: "918@c.us", message_id: "m2", type: nil)
88
+ end
89
+
90
+ it "returns an empty array when the inbound body is empty" do
91
+ empty = instance_double(Net::HTTPOK, body: "", code: "200", is_a?: true)
92
+ allow(Net::HTTP).to receive(:start).and_return(empty)
93
+
94
+ expect(adapter.fetch_inbound(metadata: {})).to eq([])
95
+ end
96
+
97
+ it "raises when the inbound fetch fails" do
98
+ allow(Net::HTTP).to receive(:start).and_return(http_failure(code: "500", body: JSON.generate({ error: "down" })))
99
+
100
+ expect { adapter.fetch_inbound(metadata: {}) }.to raise_error(/service request failed/)
101
+ end
102
+
56
103
  it "logs out via the service" do
57
104
  allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
58
105
 
@@ -93,13 +93,30 @@ RSpec.describe WhatsAppNotifier do
93
93
  allow(fake_client).to receive(:deliver_bulk).and_return(total: 0, success: 0, failed: 0, results: [])
94
94
  allow(fake_client).to receive(:scan_qr).and_return("qr-code")
95
95
  allow(fake_client).to receive(:connection_status).and_return(state: "QR_REQUIRED")
96
+ allow(fake_client).to receive(:fetch_inbound).and_return([{ from: "q@c.us" }])
96
97
  allow(fake_client).to receive(:logout).and_return(success: true)
97
98
  described_class.instance_variable_set(:@client, fake_client)
98
99
 
99
100
  expect(described_class.deliver_bulk([], provider: :web_automation)[:total]).to eq(0)
100
101
  expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
101
102
  expect(described_class.connection_status(provider: :web_automation, metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
103
+ expect(described_class.fetch_inbound(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "q@c.us" }])
102
104
  expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
103
105
  end
104
106
 
107
+ it "fetches inbound through the module API end to end" do
108
+ adapter = double(
109
+ send_message: { success: true, session: {} },
110
+ fetch_qr_code: "qr",
111
+ connection_status: { state: "AUTHENTICATED", authenticated: true },
112
+ fetch_inbound: [{ from: "z@c.us", body: "ping" }]
113
+ )
114
+ described_class.configure do |config|
115
+ config.provider = :web_automation
116
+ config.web_automation_enabled = true
117
+ config.web_adapter = adapter
118
+ end
119
+
120
+ expect(described_class.fetch_inbound(metadata: { user_id: 1 })).to eq([{ from: "z@c.us", body: "ping" }])
121
+ end
105
122
  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.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -79,6 +79,8 @@ files:
79
79
  - lib/whatsapp_notifier/railtie.rb
80
80
  - lib/whatsapp_notifier/result.rb
81
81
  - lib/whatsapp_notifier/services/web_automation/bun.lock
82
+ - lib/whatsapp_notifier/services/web_automation/inbound.test.ts
83
+ - lib/whatsapp_notifier/services/web_automation/inbound.ts
82
84
  - lib/whatsapp_notifier/services/web_automation/index.ts
83
85
  - lib/whatsapp_notifier/services/web_automation/node_modules/@babel/code-frame/LICENSE
84
86
  - lib/whatsapp_notifier/services/web_automation/node_modules/@babel/code-frame/README.md