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