whatsapp_notifier 0.3.1 → 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: 58d5e7ea12d1eb8560b911c6c84595f4ee1590cb64d5351ecc4d3d5e5af8245d
4
- data.tar.gz: c743fca10de3d4cd9b21789822dc8b6dff4dbfe9ffc9a5a6b119d257386c08a7
3
+ metadata.gz: fc24f1c67db14b3be6dfc49310e7a86987c520c6ddc595895d6e00b483aafab4
4
+ data.tar.gz: e600799c44eeefddfa3476c65894bc1aa430d7c488685e7172c9dcaf5e9c27df
5
5
  SHA512:
6
- metadata.gz: 07b15329406ab0f4f64013f60108b66d40906277acb5ae9c980688e9dfb11317fb2988a2e7ca73c9945c0853bdf46071f94af8f1ba0e5cdb81bb7595dd6750e7
7
- data.tar.gz: 932f108caf6a2ee6af828a4e23d41b1bacdedc308f1d59d41ac6cdd03f9cfde1e3892f6065eaf888c80af238a73a641bc42549b5a5a4df4972511b1582063c76
6
+ metadata.gz: f85d85f6e467055c93c8c5ba30c325a8d68f1113b5d257d3b26f8397976706fb771cff63cedadba721f50c87d24f5159a79fac570ce6e1b2a2cffe84be877b30
7
+ data.tar.gz: 747e11eeee1780b222f7e46c044dad8bd8235462dadc5b3f8f0afb2d4d2f04a84c7e71ece2bfbcbab4571a061cda6cfd2671e0e736dbe7a932d4b81d86101df4
data/README.md CHANGED
@@ -54,6 +54,20 @@ status = WhatsAppNotifier.connection_status(metadata: { user_id: current_user.id
54
54
  # => { state: "...", authenticated: true/false, has_qr: true/false }
55
55
  ```
56
56
 
57
+ ## Log out (disconnect + clear session)
58
+
59
+ Explicitly disconnects the user and wipes their saved WhatsApp session from the
60
+ service, so the next connect starts fresh with a new QR. Call this from a
61
+ user-initiated "Log out WhatsApp" action — NOT from your app's sign-out, which
62
+ should leave the WhatsApp session intact for the next login.
63
+
64
+ ```ruby
65
+ WhatsAppNotifier.logout(metadata: { user_id: current_user.id })
66
+ # => { success: true }
67
+ ```
68
+
69
+ The mounted engine also exposes `DELETE /whatsapp/logout` for the same effect.
70
+
57
71
  ## Send a message
58
72
 
59
73
  ```ruby
@@ -120,6 +134,13 @@ This copies the service to `whatsapp_service/` and updates `.gitignore`.
120
134
 
121
135
  - This gem uses WhatsApp Web automation. Use responsibly and follow WhatsApp policies.
122
136
  - Keep Chromium available in your runtime (or set `PUPPETEER_EXECUTABLE_PATH`).
137
+ - Session profiles persist under `WHATSAPP_SESSION_DIR` (default `/whatsapp_data`).
138
+ Mount it on durable storage so logins survive restarts; the service clears stale
139
+ Chromium `SingletonLock` files on each launch so an unclean exit can't wedge it.
140
+ - Resilience knobs: `WHATSAPP_INIT_TIMEOUT_MS` (default 90000) recycles a client
141
+ that never reaches QR/READY; set `WWEBJS_WEB_VERSION` (e.g. `2.3000.1023204887`,
142
+ optionally `WWEBJS_WEB_VERSION_CACHE_URL`) to pin the WhatsApp Web build so a
143
+ live web.whatsapp.com change can't silently break the client.
123
144
 
124
145
  ## License
125
146
 
@@ -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
@@ -22,6 +22,16 @@ module WhatsAppNotifier
22
22
  status: :internal_server_error
23
23
  end
24
24
 
25
+ # DELETE /logout — disconnect WhatsApp and clear the saved session for the
26
+ # current user. Explicit, user-initiated; app sign-out must NOT call this.
27
+ def destroy
28
+ WhatsAppNotifier.logout(metadata: metadata)
29
+ render json: { success: true }
30
+ rescue StandardError => e
31
+ render json: { error: "Failed to log out: #{e.message}" },
32
+ status: :internal_server_error
33
+ end
34
+
25
35
  private
26
36
 
27
37
  def metadata
data/config/routes.rb CHANGED
@@ -1,5 +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
- 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
5
7
  end
@@ -27,6 +27,14 @@ 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
+
34
+ def logout(metadata: {}, provider: nil)
35
+ provider_for(provider || @configuration.provider).logout(metadata: metadata)
36
+ end
37
+
30
38
  private
31
39
 
32
40
  def provider_for(key)
@@ -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!
@@ -1,6 +1,10 @@
1
1
  module WhatsAppNotifier
2
2
  module Jobs
3
3
  if defined?(::ActiveJob::Base)
4
+ # Real async path, only live in a host app that has ActiveJob loaded (any
5
+ # Rails app). Not exercisable in the gem's unit suite — the class is chosen
6
+ # at load time and ActiveJob isn't a gem dependency.
7
+ # :nocov:
4
8
  class SendMessageJob < ::ActiveJob::Base
5
9
  queue_as :default
6
10
 
@@ -9,6 +13,7 @@ module WhatsAppNotifier
9
13
  klass.with(params).deliver_now
10
14
  end
11
15
  end
16
+ # :nocov:
12
17
  else
13
18
  class SendMessageJob
14
19
  def self.perform_later(notification_class_name, params)
@@ -48,6 +48,28 @@ 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
+
64
+ def logout(metadata: {})
65
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
66
+
67
+ adapter = configuration.web_adapter
68
+ raise ConfigurationError, "web_adapter must be configured for web_automation provider" unless adapter.respond_to?(:logout)
69
+
70
+ adapter.logout(metadata: metadata)
71
+ end
72
+
51
73
 
52
74
  private
53
75
 
@@ -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,12 +3,33 @@ 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);
9
19
  const SESSION_BASE_DIR = process.env.WHATSAPP_SESSION_DIR || '/whatsapp_data';
10
20
  const BROWSER_EXECUTABLE_PATH = process.env.PUPPETEER_EXECUTABLE_PATH;
11
21
 
22
+ // Recycle a client that boots Chromium but never reaches QR/READY (e.g. after a
23
+ // WhatsApp Web update breaks the injected store), instead of wedging in
24
+ // INITIALIZING forever with no QR.
25
+ const INIT_TIMEOUT_MS = Number(process.env.WHATSAPP_INIT_TIMEOUT_MS || 90000);
26
+ // Optionally pin the WhatsApp Web build so a live web.whatsapp.com change can't
27
+ // silently break the client. Set WWEBJS_WEB_VERSION to a known-good version
28
+ // (e.g. "2.3000.1023204887"); leave unset to use the library default.
29
+ const WEB_VERSION = process.env.WWEBJS_WEB_VERSION || null;
30
+ const WEB_VERSION_CACHE_URL = process.env.WWEBJS_WEB_VERSION_CACHE_URL ||
31
+ 'https://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/{version}.html';
32
+
12
33
  // Multi-user client management
13
34
  interface ClientData {
14
35
  client: Client;
@@ -17,11 +38,85 @@ interface ClientData {
17
38
  lastUsed: number;
18
39
  isDestroying?: boolean;
19
40
  ready?: boolean;
41
+ initTimer?: ReturnType<typeof setTimeout>;
20
42
  }
21
43
 
22
44
  const clients = new Map<string, ClientData>();
23
45
  const initializingClients = new Set<string>();
24
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
+
113
+ function clearInitTimer(clientData: ClientData) {
114
+ if (clientData.initTimer) {
115
+ clearTimeout(clientData.initTimer);
116
+ clientData.initTimer = undefined;
117
+ }
118
+ }
119
+
25
120
  function sessionDirForUser(userId: string) {
26
121
  return join(SESSION_BASE_DIR, `session-user-${userId}`);
27
122
  }
@@ -135,7 +230,11 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
135
230
  '--disable-gpu'
136
231
  ],
137
232
  ...(BROWSER_EXECUTABLE_PATH ? { executablePath: BROWSER_EXECUTABLE_PATH } : {})
138
- }
233
+ },
234
+ ...(WEB_VERSION ? {
235
+ webVersion: WEB_VERSION,
236
+ webVersionCache: { type: 'remote' as const, remotePath: WEB_VERSION_CACHE_URL }
237
+ } : {})
139
238
  });
140
239
 
141
240
  const clientData: ClientData = {
@@ -148,8 +247,18 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
148
247
 
149
248
  clients.set(userId, clientData);
150
249
 
250
+ // Watchdog: if the client never reaches QR/READY, recycle it so the next
251
+ // poll spins a fresh attempt instead of wedging in INITIALIZING forever.
252
+ clientData.initTimer = setTimeout(() => {
253
+ if (clientData.state === 'INITIALIZING' && !clientData.qr && !clientData.ready) {
254
+ console.error(`User ${userId} stuck INITIALIZING > ${INIT_TIMEOUT_MS}ms — recycling`);
255
+ destroyClient(userId).catch(console.error);
256
+ }
257
+ }, INIT_TIMEOUT_MS);
258
+
151
259
  client.on('qr', async (qr) => {
152
260
  clientData.state = 'QR_REQUIRED';
261
+ clearInitTimer(clientData); // progress made — QR is showable
153
262
  try {
154
263
  clientData.qr = await toDataURL(qr);
155
264
  console.log(`QR RECEIVED and converted for User ${userId}`);
@@ -162,18 +271,34 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
162
271
  clientData.state = 'AUTHENTICATED';
163
272
  clientData.qr = null;
164
273
  clientData.ready = true;
274
+ clearInitTimer(clientData);
165
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));
166
278
  });
167
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
+
168
291
  client.on('authenticated', () => {
169
292
  clientData.state = 'AUTHENTICATED';
170
293
  clientData.ready = false;
294
+ clearInitTimer(clientData);
171
295
  console.log(`AUTHENTICATED for User ${userId}`);
172
296
  });
173
297
 
174
298
  client.on('auth_failure', (msg) => {
175
299
  clientData.state = 'DISCONNECTED';
176
300
  clientData.ready = false;
301
+ clearInitTimer(clientData);
177
302
  console.error(`AUTHENTICATION FAILURE for User ${userId}`, msg);
178
303
  });
179
304
 
@@ -186,6 +311,7 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
186
311
 
187
312
  client.initialize().catch(async (err) => {
188
313
  console.error(`Initialization failed for user ${userId}:`, err.message || err);
314
+ clearInitTimer(clientData);
189
315
  // Clean up the failed client
190
316
  try { client.removeAllListeners(); } catch (_) {}
191
317
  try { await client.destroy(); } catch (_) {}
@@ -203,11 +329,12 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
203
329
  return clientData;
204
330
  }
205
331
 
206
- async function destroyClient(userId: string) {
332
+ async function destroyClient(userId: string, clearSession: boolean = false) {
207
333
  const data = clients.get(userId);
208
334
  if (data && !data.isDestroying) {
209
335
  data.isDestroying = true;
210
- console.log(`Destroying WhatsApp client for User: ${userId}`);
336
+ clearInitTimer(data);
337
+ console.log(`Destroying WhatsApp client for User: ${userId} (clearSession: ${clearSession})`);
211
338
  try {
212
339
  // Unregister listeners to prevent loops or double-destroys
213
340
  data.client.removeAllListeners();
@@ -216,7 +343,18 @@ async function destroyClient(userId: string) {
216
343
  console.error(`Error destroying client for ${userId}`, e);
217
344
  }
218
345
  clients.delete(userId);
219
- // Session data is preserved on disk for auto-reconnect
346
+ // Session data is preserved on disk for auto-reconnect (unless clearing below).
347
+ }
348
+ // On explicit logout, wipe the persisted Chromium profile / WhatsApp session
349
+ // from disk — even if there was no live client in memory.
350
+ if (clearSession) {
351
+ const dir = sessionDirForUser(userId);
352
+ try {
353
+ rmSync(dir, { recursive: true, force: true });
354
+ console.log(`Cleared session dir for User: ${userId}`);
355
+ } catch (e) {
356
+ console.error(`Failed to clear session dir for ${userId}`, e);
357
+ }
220
358
  }
221
359
  }
222
360
 
@@ -250,6 +388,17 @@ app.get('/qr/:userId', async (c) => {
250
388
  return c.json({ qr: data.qr });
251
389
  });
252
390
 
391
+ // Explicit per-user logout: disconnect the client and wipe the saved WhatsApp
392
+ // session from disk so the next connect starts fresh with a new QR. Triggered by
393
+ // the user-settings "Log out WhatsApp" button — NOT by app sign-out.
394
+ app.post('/logout/:userId', async (c) => {
395
+ const userId = c.req.param('userId');
396
+ console.log(`Logout requested for User: ${userId}`);
397
+ await destroyClient(userId, true);
398
+ initializingClients.delete(userId);
399
+ return c.json({ success: true });
400
+ });
401
+
253
402
  app.post('/send/:userId', async (c) => {
254
403
  const userId = c.req.param('userId');
255
404
  const { to, message, mediaUrl } = await c.req.json();
@@ -266,6 +415,9 @@ app.post('/send/:userId', async (c) => {
266
415
  const chatId = to.includes('@c.us') ? to : `${to}@c.us`;
267
416
  await sendMessageWithRetry(data.client, data, chatId, message, mediaUrl);
268
417
 
418
+ // Record the recipient so their replies pass the inbound allowlist.
419
+ rememberTarget(userId, chatId);
420
+
269
421
  data.lastUsed = Date.now();
270
422
  return c.json({ success: true });
271
423
  } catch (error: any) {
@@ -274,6 +426,15 @@ app.post('/send/:userId', async (c) => {
274
426
  }
275
427
  });
276
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
+
277
438
 
278
439
  console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
279
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,39 +9,15 @@ 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))
22
20
  end
23
-
24
- private
25
-
26
- def cached_qr(session, user_id)
27
- return session[:qr_code] unless user_id
28
-
29
- session.fetch(:users, {}).fetch(user_key(user_id), {})[:qr_code]
30
- end
31
-
32
- def with_cached_qr(session, user_id, qr_code)
33
- return session.merge(qr_code: qr_code) unless user_id
34
-
35
- users = session.fetch(:users, {})
36
- key = user_key(user_id)
37
- user_session = users.fetch(key, {})
38
- users[key] = user_session.merge(qr_code: qr_code)
39
- session.merge(users: users)
40
- end
41
-
42
- def user_key(user_id)
43
- user_id.to_s.to_sym
44
- end
45
21
  end
46
22
  end
47
23
  end
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.3.1"
2
+ VERSION = "0.5.1"
3
3
 
4
4
  end
@@ -52,6 +52,32 @@ 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
+
74
+ # Logs the user out of WhatsApp and clears their saved session on the service.
75
+ def logout(metadata: {})
76
+ user_id = user_id_from(metadata)
77
+ response = request(:post, "/logout/#{user_id}")
78
+ { success: response.fetch("success", false) }
79
+ end
80
+
55
81
  private
56
82
 
57
83
  def user_id_from(metadata)
@@ -62,6 +62,14 @@ 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
+
69
+ def logout(provider: nil, metadata: {})
70
+ client.logout(provider: provider, metadata: metadata)
71
+ end
72
+
65
73
  end
66
74
  end
67
75
 
data/spec/client_spec.rb CHANGED
@@ -49,4 +49,38 @@ RSpec.describe WhatsAppNotifier::Client do
49
49
  expect(status).to include(state: "QR_REQUIRED")
50
50
  end
51
51
  end
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
+
70
+ it "delegates logout to the provider" do
71
+ Dir.mktmpdir do |dir|
72
+ config.provider = :web_automation
73
+ config.web_automation_enabled = true
74
+ config.web_session_path = File.join(dir, "session.json")
75
+ config.web_adapter = double(
76
+ send_message: { success: true, session: {} },
77
+ fetch_qr_code: "qr",
78
+ connection_status: { state: "AUTHENTICATED", authenticated: true },
79
+ logout: { success: true }
80
+ )
81
+ client = described_class.new(configuration: config)
82
+
83
+ expect(client.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
84
+ end
85
+ end
52
86
  end
@@ -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
@@ -24,13 +24,14 @@ RSpec.describe WhatsAppNotifier::Jobs::SendMessageJob do
24
24
  expect(result).to be_success
25
25
  end
26
26
 
27
- it "raises when perform_later has no active job base" do
28
- hide_const("ActiveJob")
29
- expect { described_class.perform_later("JobNotification", to: "+1") }.to raise_error(LoadError)
27
+ it "falls back to synchronous delivery when ActiveJob is unavailable" do
28
+ # ActiveJob is not a gem dependency, so the sync-fallback class is the one
29
+ # that loads; perform_later runs perform_now rather than raising.
30
+ result = described_class.perform_later("JobNotification", to: "+1")
31
+ expect(result).to be_success
30
32
  end
31
33
 
32
- it "allows perform_later when active job base is present" do
33
- stub_const("ActiveJob::Base", Class.new)
34
+ it "allows perform_later without raising" do
34
35
  expect { described_class.perform_later("JobNotification", to: "+1") }.not_to raise_error
35
36
  end
36
37
  end
@@ -30,14 +30,19 @@ RSpec.describe WhatsAppNotifier::Notification do
30
30
  expect(result).to be_success
31
31
  end
32
32
 
33
- it "supports deliver_later when active job exists" do
34
- stub_const("ActiveJob::Base", Class.new)
33
+ it "supports deliver_later" do
35
34
  expect { TestNotification.deliver_later(params: { name: "Neha" }) }.not_to raise_error
36
35
  end
37
36
 
38
- it "raises for deliver_later without active job" do
39
- hide_const("ActiveJob")
40
- expect { TestNotification.deliver_later(params: { name: "Neha" }) }.to raise_error(LoadError)
37
+ it "falls back to synchronous delivery for deliver_later without active job" do
38
+ # No ActiveJob dependency → deliver_later runs the job synchronously.
39
+ result = TestNotification.deliver_later(params: { name: "Neha" })
40
+ expect(result).to be_success
41
+ end
42
+
43
+ it "supports instance-level deliver_later" do
44
+ result = TestNotification.with(params: { name: "Neha" }).deliver_later
45
+ expect(result).to be_success
41
46
  end
42
47
 
43
48
  it "raises when recipient is missing" do
@@ -106,4 +106,64 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
106
106
  expect(adapter).to have_received(:send_message).with(payload: hash_including(metadata: { user_id: "user-b" }), session: {}).once
107
107
  end
108
108
  end
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
+
140
+ it "logs out via adapter when enabled" do
141
+ Dir.mktmpdir do |dir|
142
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
143
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
144
+ provider = described_class.new(configuration: config)
145
+
146
+ expect(provider.logout(metadata: { user_id: 1 })).to eq(success: true)
147
+ end
148
+ end
149
+
150
+ it "raises on logout when provider disabled" do
151
+ Dir.mktmpdir do |dir|
152
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
153
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
154
+ provider = described_class.new(configuration: config)
155
+
156
+ expect { provider.logout }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
157
+ end
158
+ end
159
+
160
+ it "raises for missing adapter logout method" do
161
+ Dir.mktmpdir do |dir|
162
+ adapter = Object.new
163
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
164
+ provider = described_class.new(configuration: config)
165
+
166
+ expect { provider.logout }.to raise_error(WhatsAppNotifier::ConfigurationError, /web_adapter/)
167
+ end
168
+ end
109
169
  end
@@ -12,7 +12,7 @@ RSpec.describe WhatsAppNotifier::Session::QrService do
12
12
  end
13
13
  end
14
14
 
15
- it "caches qr code per user_id when metadata is provided" do
15
+ it "fetches a fresh qr on every call (codes expire, so no caching) and passes metadata" do
16
16
  Dir.mktmpdir do |dir|
17
17
  store = WhatsAppNotifier::Session::Store.new(path: File.join(dir, "s.json"))
18
18
  adapter = double
@@ -20,10 +20,9 @@ RSpec.describe WhatsAppNotifier::Session::QrService do
20
20
  service = described_class.new(store: store, adapter: adapter)
21
21
 
22
22
  expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-1")
23
- expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-1")
24
- expect(service.qr_code(metadata: { user_id: 2 })).to eq("qr-user-2")
23
+ expect(service.qr_code(metadata: { user_id: 1 })).to eq("qr-user-2")
25
24
 
26
- expect(adapter).to have_received(:fetch_qr_code).twice
25
+ expect(adapter).to have_received(:fetch_qr_code).with(metadata: { user_id: 1 }).twice
27
26
  end
28
27
  end
29
28
 
@@ -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 })
@@ -52,4 +66,59 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
52
66
  expect(adapter.fetch_qr_code(metadata: {})).to be_nil
53
67
  expect { adapter.connection_status(metadata: {}) }.to raise_error(/raw-error/)
54
68
  end
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
+
103
+ it "logs out via the service" do
104
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
105
+
106
+ expect(adapter.logout(metadata: { user_id: "u-1" })).to eq(success: true)
107
+ end
108
+
109
+ it "defaults logout success to false when the service omits it" do
110
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: {}))
111
+
112
+ expect(adapter.logout(metadata: {})).to eq(success: false)
113
+ end
114
+
115
+ it "executes the request inside the Net::HTTP block" do
116
+ fake_http = instance_double(Net::HTTP)
117
+ allow(fake_http).to receive(:request).and_return(http_success(body: { "qr" => "data:image/png;base64,x" }))
118
+ # Invoke the block so the real request path runs (other specs stub it away).
119
+ allow(Net::HTTP).to receive(:start) { |*_args, &blk| blk.call(fake_http) }
120
+
121
+ expect(adapter.fetch_qr_code(metadata: { user_id: 1 })).to eq("data:image/png;base64,x")
122
+ expect(fake_http).to have_received(:request)
123
+ end
55
124
  end
@@ -93,11 +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" }])
97
+ allow(fake_client).to receive(:logout).and_return(success: true)
96
98
  described_class.instance_variable_set(:@client, fake_client)
97
99
 
98
100
  expect(described_class.deliver_bulk([], provider: :web_automation)[:total]).to eq(0)
99
101
  expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
100
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" }])
104
+ expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
101
105
  end
102
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
103
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.3.1
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