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 +4 -4
- data/README.md +21 -0
- data/app/controllers/whatsapp_notifier/messages_controller.rb +13 -0
- data/app/controllers/whatsapp_notifier/sessions_controller.rb +10 -0
- data/config/routes.rb +5 -3
- data/lib/whatsapp_notifier/client.rb +8 -0
- data/lib/whatsapp_notifier/configuration.rb +4 -1
- data/lib/whatsapp_notifier/jobs/send_message_job.rb +5 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +22 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +106 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +119 -0
- data/lib/whatsapp_notifier/services/web_automation/index.ts +165 -4
- data/lib/whatsapp_notifier/services/web_automation/package.json +3 -0
- data/lib/whatsapp_notifier/session/qr_service.rb +3 -27
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +26 -0
- data/lib/whatsapp_notifier.rb +8 -0
- data/spec/client_spec.rb +34 -0
- data/spec/configuration_spec.rb +16 -0
- data/spec/jobs/send_message_job_spec.rb +6 -5
- data/spec/notification_spec.rb +10 -5
- data/spec/providers/web_automation_spec.rb +60 -0
- data/spec/session/qr_service_spec.rb +3 -4
- data/spec/web_adapter_spec.rb +69 -0
- data/spec/whatsapp_notifier_spec.rb +19 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc24f1c67db14b3be6dfc49310e7a86987c520c6ddc595895d6e00b483aafab4
|
|
4
|
+
data.tar.gz: e600799c44eeefddfa3476c65894bc1aa430d7c488685e7172c9dcaf5e9c27df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
3
|
-
get
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -9,39 +9,15 @@ module WhatsAppNotifier
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def qr_code(metadata: {})
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
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
|
|
@@ -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)
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -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
|
data/spec/configuration_spec.rb
CHANGED
|
@@ -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 "
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
data/spec/notification_spec.rb
CHANGED
|
@@ -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
|
|
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 "
|
|
39
|
-
|
|
40
|
-
|
|
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 "
|
|
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-
|
|
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
|
|
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -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.
|
|
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
|