whatsapp_notifier 0.2.0
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 +7 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/Rakefile +6 -0
- data/bin/whatsapp_notifier +53 -0
- data/docs/bulk_messaging_policy.md +30 -0
- data/docs/graphify.md +108 -0
- data/docs/rails_setup.md +57 -0
- data/examples/notification_example.rb +14 -0
- data/lib/generators/whatsapp_notifier/install_generator.rb +60 -0
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +33 -0
- data/lib/generators/whatsapp_notifier/templates/whatsapp_notifier.rb +6 -0
- data/lib/whatsapp_notifier/bulk/dispatcher.rb +64 -0
- data/lib/whatsapp_notifier/bulk/rate_limiter.rb +17 -0
- data/lib/whatsapp_notifier/bulk/retry_policy.rb +32 -0
- data/lib/whatsapp_notifier/client.rb +43 -0
- data/lib/whatsapp_notifier/configuration.rb +41 -0
- data/lib/whatsapp_notifier/doctor.rb +103 -0
- data/lib/whatsapp_notifier/errors.rb +5 -0
- data/lib/whatsapp_notifier/jobs/send_message_job.rb +20 -0
- data/lib/whatsapp_notifier/notification.rb +93 -0
- data/lib/whatsapp_notifier/providers/base.rb +24 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +85 -0
- data/lib/whatsapp_notifier/railtie.rb +14 -0
- data/lib/whatsapp_notifier/result.rb +23 -0
- data/lib/whatsapp_notifier/services/web_automation/bun.lock +452 -0
- data/lib/whatsapp_notifier/services/web_automation/index.ts +285 -0
- data/lib/whatsapp_notifier/services/web_automation/package.json +14 -0
- data/lib/whatsapp_notifier/session/qr_service.rb +51 -0
- data/lib/whatsapp_notifier/session/store.rb +22 -0
- data/lib/whatsapp_notifier/version.rb +4 -0
- data/lib/whatsapp_notifier/web_adapter.rb +72 -0
- data/lib/whatsapp_notifier.rb +72 -0
- data/spec/bulk/dispatcher_spec.rb +73 -0
- data/spec/bulk/rate_limiter_spec.rb +27 -0
- data/spec/bulk/retry_policy_spec.rb +33 -0
- data/spec/client_spec.rb +52 -0
- data/spec/configuration_spec.rb +47 -0
- data/spec/doctor_spec.rb +46 -0
- data/spec/jobs/send_message_job_spec.rb +36 -0
- data/spec/notification_spec.rb +60 -0
- data/spec/providers/base_spec.rb +17 -0
- data/spec/providers/web_automation_spec.rb +109 -0
- data/spec/railtie_spec.rb +37 -0
- data/spec/result_spec.rb +12 -0
- data/spec/session/qr_service_spec.rb +42 -0
- data/spec/session/store_spec.rb +21 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/web_adapter_spec.rb +55 -0
- data/spec/whatsapp_notifier_spec.rb +102 -0
- metadata +126 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Client, LocalAuth } from 'whatsapp-web.js';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { rmSync, existsSync, readdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { toDataURL } from 'qrcode';
|
|
6
|
+
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
const port = Number(process.env.PORT || 3001);
|
|
9
|
+
const SESSION_BASE_DIR = process.env.WHATSAPP_SESSION_DIR || '/whatsapp_data';
|
|
10
|
+
const BROWSER_EXECUTABLE_PATH = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
11
|
+
|
|
12
|
+
// Multi-user client management
|
|
13
|
+
interface ClientData {
|
|
14
|
+
client: Client;
|
|
15
|
+
state: 'INITIALIZING' | 'QR_REQUIRED' | 'AUTHENTICATED' | 'DISCONNECTED';
|
|
16
|
+
qr: string | null;
|
|
17
|
+
lastUsed: number;
|
|
18
|
+
isDestroying?: boolean;
|
|
19
|
+
ready?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const clients = new Map<string, ClientData>();
|
|
23
|
+
|
|
24
|
+
function sessionDirForUser(userId: string) {
|
|
25
|
+
return join(SESSION_BASE_DIR, `session-user-${userId}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clearChromiumSingletonLocks(userId: string) {
|
|
29
|
+
const lockFiles = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'lockfile'];
|
|
30
|
+
|
|
31
|
+
// Clean locks from session dir and all subdirectories recursively
|
|
32
|
+
function cleanDir(dir: string) {
|
|
33
|
+
if (!existsSync(dir)) return;
|
|
34
|
+
|
|
35
|
+
lockFiles.forEach((fileName) => {
|
|
36
|
+
const filePath = join(dir, fileName);
|
|
37
|
+
try {
|
|
38
|
+
rmSync(filePath, { force: true });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// Ignore
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
cleanDir(join(dir, entry.name));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (_) { /* ignore permission errors on nested dirs */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cleanDir(sessionDirForUser(userId));
|
|
54
|
+
// Also clean the base session dir itself
|
|
55
|
+
cleanDir(SESSION_BASE_DIR);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isTransientSendError(error: unknown) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
return message.includes("getChat") || message.includes("Cannot read properties of undefined");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForClientReady(clientData: ClientData, timeoutMs = 30000): Promise<void> {
|
|
64
|
+
if (clientData.ready) return;
|
|
65
|
+
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
while (Date.now() - start < timeoutMs) {
|
|
68
|
+
if (clientData.ready) return;
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
70
|
+
}
|
|
71
|
+
throw new Error('Client not ready: WhatsApp Web store did not initialize in time');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function sendMessageWithRetry(client: Client, clientData: ClientData, chatId: string, message: string, mediaUrl?: string | null) {
|
|
75
|
+
const maxAttempts = 5;
|
|
76
|
+
|
|
77
|
+
// Wait for the internal WWeb store to be fully loaded before first attempt
|
|
78
|
+
await waitForClientReady(clientData);
|
|
79
|
+
|
|
80
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
81
|
+
try {
|
|
82
|
+
if (mediaUrl) {
|
|
83
|
+
const { MessageMedia } = require('whatsapp-web.js');
|
|
84
|
+
const media = await MessageMedia.fromUrl(mediaUrl);
|
|
85
|
+
await client.sendMessage(chatId, media, { caption: message });
|
|
86
|
+
} else {
|
|
87
|
+
await client.sendMessage(chatId, message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(`Send attempt ${attempt}/${maxAttempts} failed for chat ${chatId}:`, error);
|
|
93
|
+
|
|
94
|
+
if (!isTransientSendError(error) || attempt === maxAttempts) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Wait longer between retries to give the store time to hydrate
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 3000));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Helper to get or create a client for a user
|
|
105
|
+
async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
106
|
+
if (clients.has(userId)) {
|
|
107
|
+
const data = clients.get(userId)!;
|
|
108
|
+
if (data.isDestroying) return data;
|
|
109
|
+
data.lastUsed = Date.now();
|
|
110
|
+
return data;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(`Initializing new WhatsApp client for User: ${userId}`);
|
|
114
|
+
clearChromiumSingletonLocks(userId);
|
|
115
|
+
|
|
116
|
+
const client = new Client({
|
|
117
|
+
authStrategy: new LocalAuth({
|
|
118
|
+
clientId: `user-${userId}`,
|
|
119
|
+
dataPath: SESSION_BASE_DIR
|
|
120
|
+
}),
|
|
121
|
+
puppeteer: {
|
|
122
|
+
headless: true,
|
|
123
|
+
args: [
|
|
124
|
+
'--no-sandbox',
|
|
125
|
+
'--disable-setuid-sandbox',
|
|
126
|
+
'--disable-dev-shm-usage',
|
|
127
|
+
'--disable-gpu'
|
|
128
|
+
],
|
|
129
|
+
...(BROWSER_EXECUTABLE_PATH ? { executablePath: BROWSER_EXECUTABLE_PATH } : {})
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const clientData: ClientData = {
|
|
134
|
+
client,
|
|
135
|
+
state: 'INITIALIZING',
|
|
136
|
+
qr: null,
|
|
137
|
+
lastUsed: Date.now(),
|
|
138
|
+
ready: false
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
clients.set(userId, clientData);
|
|
142
|
+
|
|
143
|
+
client.on('qr', async (qr) => {
|
|
144
|
+
clientData.state = 'QR_REQUIRED';
|
|
145
|
+
try {
|
|
146
|
+
clientData.qr = await toDataURL(qr);
|
|
147
|
+
console.log(`QR RECEIVED and converted for User ${userId}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('Failed to convert QR to DataURL', err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
client.on('ready', () => {
|
|
154
|
+
clientData.state = 'AUTHENTICATED';
|
|
155
|
+
clientData.qr = null;
|
|
156
|
+
clientData.ready = true;
|
|
157
|
+
console.log(`Client is READY for User ${userId}`);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
client.on('authenticated', () => {
|
|
161
|
+
clientData.state = 'AUTHENTICATED';
|
|
162
|
+
clientData.ready = false;
|
|
163
|
+
console.log(`AUTHENTICATED for User ${userId}`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
client.on('auth_failure', (msg) => {
|
|
167
|
+
clientData.state = 'DISCONNECTED';
|
|
168
|
+
clientData.ready = false;
|
|
169
|
+
console.error(`AUTHENTICATION FAILURE for User ${userId}`, msg);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
client.on('disconnected', (reason) => {
|
|
173
|
+
clientData.state = 'DISCONNECTED';
|
|
174
|
+
clientData.ready = false;
|
|
175
|
+
console.log(`User ${userId} was logged out`, reason);
|
|
176
|
+
destroyClient(userId).catch(console.error);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
client.initialize().catch(err => {
|
|
180
|
+
console.error(`Initialization failed for user ${userId}`, err);
|
|
181
|
+
clients.delete(userId);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return clientData;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function destroyClient(userId: string) {
|
|
188
|
+
const data = clients.get(userId);
|
|
189
|
+
if (data && !data.isDestroying) {
|
|
190
|
+
data.isDestroying = true;
|
|
191
|
+
console.log(`Destroying WhatsApp client for User: ${userId}`);
|
|
192
|
+
try {
|
|
193
|
+
// Unregister listeners to prevent loops or double-destroys
|
|
194
|
+
data.client.removeAllListeners();
|
|
195
|
+
await data.client.destroy();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`Error destroying client for ${userId}`, e);
|
|
198
|
+
}
|
|
199
|
+
clients.delete(userId);
|
|
200
|
+
|
|
201
|
+
// Optional: Clean up session files and puppeteer profiles if it was a logout
|
|
202
|
+
const userPath = sessionDirForUser(userId);
|
|
203
|
+
|
|
204
|
+
if (existsSync(userPath)) {
|
|
205
|
+
try {
|
|
206
|
+
rmSync(userPath, { recursive: true, force: true });
|
|
207
|
+
console.log(`Cleaned up user data directory: ${userPath}`);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error(`Failed to delete directory: ${userPath}`, e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 72-hour stagnation cleanup
|
|
216
|
+
setInterval(() => {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const STAGNATION_LIMIT = 72 * 60 * 60 * 1000; // 72 hours
|
|
219
|
+
|
|
220
|
+
for (const [userId, data] of clients.entries()) {
|
|
221
|
+
if (now - data.lastUsed > STAGNATION_LIMIT) {
|
|
222
|
+
console.log(`Auto-cleaning stagnant session for User: ${userId}`);
|
|
223
|
+
destroyClient(userId).catch(console.error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, 60 * 60 * 1000); // Check every hour
|
|
227
|
+
|
|
228
|
+
// API Routes
|
|
229
|
+
app.get('/status/:userId', async (c) => {
|
|
230
|
+
const userId = c.req.param('userId');
|
|
231
|
+
const data = await getOrCreateClient(userId);
|
|
232
|
+
return c.json({
|
|
233
|
+
state: data.state,
|
|
234
|
+
authenticated: data.state === 'AUTHENTICATED' && !!data.ready,
|
|
235
|
+
hasQR: !!data.qr
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
app.get('/qr/:userId', async (c) => {
|
|
240
|
+
const userId = c.req.param('userId');
|
|
241
|
+
const data = await getOrCreateClient(userId);
|
|
242
|
+
return c.json({ qr: data.qr });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
app.post('/send/:userId', async (c) => {
|
|
246
|
+
const userId = c.req.param('userId');
|
|
247
|
+
const { to, message, mediaUrl } = await c.req.json();
|
|
248
|
+
if (!to || !message) {
|
|
249
|
+
return c.json({ success: false, error: 'Both `to` and `message` are required' }, 422);
|
|
250
|
+
}
|
|
251
|
+
const data = await getOrCreateClient(userId);
|
|
252
|
+
|
|
253
|
+
if (data.state !== 'AUTHENTICATED' || !data.ready) {
|
|
254
|
+
return c.json({ success: false, error: 'User not authenticated' }, 401);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const chatId = to.includes('@c.us') ? to : `${to}@c.us`;
|
|
259
|
+
await sendMessageWithRetry(data.client, data, chatId, message, mediaUrl);
|
|
260
|
+
|
|
261
|
+
data.lastUsed = Date.now();
|
|
262
|
+
return c.json({ success: true });
|
|
263
|
+
} catch (error: any) {
|
|
264
|
+
console.error(`Send error for user ${userId}:`, error);
|
|
265
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
|
|
271
|
+
|
|
272
|
+
process.on('exit', (code) => {
|
|
273
|
+
console.log(`BUN PROCESS EXITING WITH CODE: ${code}`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
process.on('SIGTERM', () => {
|
|
277
|
+
console.log('BUN RECEIVED SIGTERM');
|
|
278
|
+
process.exit(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Correct way to keep Bun process alive with Hono
|
|
282
|
+
export default Bun.serve({
|
|
283
|
+
port: port,
|
|
284
|
+
fetch: app.fetch,
|
|
285
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whatsapp-service",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.ts",
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"hono": "4.12.15",
|
|
7
|
+
"whatsapp-web.js": "1.34.7",
|
|
8
|
+
"qrcode": "1.5.4",
|
|
9
|
+
"puppeteer": "22.15.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"bun-types": "1.3.13"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module WhatsAppNotifier
|
|
2
|
+
module Session
|
|
3
|
+
class QrService
|
|
4
|
+
attr_reader :store, :adapter
|
|
5
|
+
|
|
6
|
+
def initialize(store:, adapter:)
|
|
7
|
+
@store = store
|
|
8
|
+
@adapter = adapter
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def qr_code(metadata: {})
|
|
12
|
+
session = store.load
|
|
13
|
+
user_id = metadata[:user_id]
|
|
14
|
+
cached = cached_qr(session, user_id)
|
|
15
|
+
return cached if cached
|
|
16
|
+
|
|
17
|
+
generated = adapter.fetch_qr_code(metadata: metadata)
|
|
18
|
+
store.save(with_cached_qr(session, user_id, generated))
|
|
19
|
+
generated
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def activate!(token)
|
|
24
|
+
session = store.load
|
|
25
|
+
store.save(session.merge(active: true, token: token, qr_code: nil))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cached_qr(session, user_id)
|
|
31
|
+
return session[:qr_code] unless user_id
|
|
32
|
+
|
|
33
|
+
session.fetch(:users, {}).fetch(user_key(user_id), {})[:qr_code]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def with_cached_qr(session, user_id, qr_code)
|
|
37
|
+
return session.merge(qr_code: qr_code) unless user_id
|
|
38
|
+
|
|
39
|
+
users = session.fetch(:users, {})
|
|
40
|
+
key = user_key(user_id)
|
|
41
|
+
user_session = users.fetch(key, {})
|
|
42
|
+
users[key] = user_session.merge(qr_code: qr_code)
|
|
43
|
+
session.merge(users: users)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def user_key(user_id)
|
|
47
|
+
user_id.to_s.to_sym
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module WhatsAppNotifier
|
|
5
|
+
module Session
|
|
6
|
+
class Store
|
|
7
|
+
def initialize(path:)
|
|
8
|
+
@path = path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def load
|
|
12
|
+
return {} unless File.exist?(@path)
|
|
13
|
+
JSON.parse(File.read(@path), symbolize_names: true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def save(data)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
File.write(@path, JSON.generate(data))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module WhatsAppNotifier
|
|
6
|
+
class WebAdapter
|
|
7
|
+
def initialize(base_url: ENV.fetch("WHATSAPP_NOTIFIER_SERVICE_URL", "http://127.0.0.1:3001"))
|
|
8
|
+
@base_url = base_url
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def send_message(payload:, session: {})
|
|
12
|
+
user_id = user_id_from(payload[:metadata] || {})
|
|
13
|
+
body = {
|
|
14
|
+
to: payload[:to],
|
|
15
|
+
message: payload[:body],
|
|
16
|
+
mediaUrl: payload.dig(:metadata, :media_url)
|
|
17
|
+
}.compact
|
|
18
|
+
|
|
19
|
+
response = request(:post, "/send/#{user_id}", body: body)
|
|
20
|
+
{
|
|
21
|
+
success: response.fetch("success"),
|
|
22
|
+
message_id: payload[:idempotency_key] || "local-#{Time.now.to_i}",
|
|
23
|
+
session: session,
|
|
24
|
+
error_message: response["error"]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch_qr_code(metadata: {})
|
|
29
|
+
user_id = user_id_from(metadata)
|
|
30
|
+
response = request(:get, "/qr/#{user_id}")
|
|
31
|
+
response["qr"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def connection_status(metadata: {})
|
|
35
|
+
user_id = user_id_from(metadata)
|
|
36
|
+
response = request(:get, "/status/#{user_id}")
|
|
37
|
+
{
|
|
38
|
+
state: response["state"],
|
|
39
|
+
authenticated: response["authenticated"],
|
|
40
|
+
has_qr: response["hasQR"]
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def user_id_from(metadata)
|
|
47
|
+
(metadata[:user_id] || metadata["user_id"] || "default").to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request(method, path, body: nil)
|
|
51
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
52
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
53
|
+
req = klass.new(uri.request_uri)
|
|
54
|
+
req["Content-Type"] = "application/json"
|
|
55
|
+
req.body = JSON.generate(body) if body
|
|
56
|
+
|
|
57
|
+
res = Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
|
|
58
|
+
parsed = parse_body(res.body)
|
|
59
|
+
return parsed if res.is_a?(Net::HTTPSuccess)
|
|
60
|
+
|
|
61
|
+
raise "service request failed (#{res.code}): #{parsed["error"] || res.body}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_body(raw)
|
|
65
|
+
return {} if raw.to_s.strip.empty?
|
|
66
|
+
|
|
67
|
+
JSON.parse(raw)
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
{ "error" => raw }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require_relative "whatsapp_notifier/version"
|
|
2
|
+
require_relative "whatsapp_notifier/errors"
|
|
3
|
+
require_relative "whatsapp_notifier/result"
|
|
4
|
+
require_relative "whatsapp_notifier/configuration"
|
|
5
|
+
require_relative "whatsapp_notifier/web_adapter"
|
|
6
|
+
require_relative "whatsapp_notifier/providers/base"
|
|
7
|
+
require_relative "whatsapp_notifier/session/store"
|
|
8
|
+
require_relative "whatsapp_notifier/session/qr_service"
|
|
9
|
+
require_relative "whatsapp_notifier/providers/web_automation"
|
|
10
|
+
require_relative "whatsapp_notifier/doctor"
|
|
11
|
+
require_relative "whatsapp_notifier/bulk/rate_limiter"
|
|
12
|
+
require_relative "whatsapp_notifier/bulk/retry_policy"
|
|
13
|
+
require_relative "whatsapp_notifier/bulk/dispatcher"
|
|
14
|
+
require_relative "whatsapp_notifier/client"
|
|
15
|
+
require_relative "whatsapp_notifier/jobs/send_message_job"
|
|
16
|
+
require_relative "whatsapp_notifier/notification"
|
|
17
|
+
|
|
18
|
+
module WhatsAppNotifier
|
|
19
|
+
class << self
|
|
20
|
+
def configure
|
|
21
|
+
yield(configuration)
|
|
22
|
+
configuration.validate!
|
|
23
|
+
@client = Client.new(configuration: configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def service_path
|
|
27
|
+
File.expand_path("whatsapp_notifier/services/web_automation", __dir__)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configuration
|
|
31
|
+
@configuration ||= Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset!
|
|
35
|
+
@configuration = Configuration.new
|
|
36
|
+
@client = Client.new(configuration: @configuration)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def client
|
|
40
|
+
@client ||= Client.new(configuration: configuration)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deliver(to:, body:, metadata: {}, provider: nil, idempotency_key: nil)
|
|
44
|
+
client.deliver(
|
|
45
|
+
to: to,
|
|
46
|
+
body: body,
|
|
47
|
+
metadata: metadata,
|
|
48
|
+
provider: provider,
|
|
49
|
+
idempotency_key: idempotency_key
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def deliver_bulk(messages, provider: nil, sleeper: ->(seconds) { sleep(seconds) }, rng: Random.new)
|
|
54
|
+
client.deliver_bulk(messages, provider: provider, sleeper: sleeper, rng: rng)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scan_qr(provider: nil, metadata: {})
|
|
58
|
+
client.scan_qr(provider: provider, metadata: metadata)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connection_status(provider: nil, metadata: {})
|
|
62
|
+
client.connection_status(provider: provider, metadata: metadata)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# :nocov:
|
|
69
|
+
if defined?(Rails::Railtie)
|
|
70
|
+
require_relative "whatsapp_notifier/railtie"
|
|
71
|
+
end
|
|
72
|
+
# :nocov:
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe WhatsAppNotifier::Bulk::Dispatcher do
|
|
4
|
+
let(:config) do
|
|
5
|
+
WhatsAppNotifier::Configuration.new.tap do |c|
|
|
6
|
+
c.bulk_base_delay_seconds = 0
|
|
7
|
+
c.bulk_jitter_seconds = 0
|
|
8
|
+
c.bulk_max_recipients = 5
|
|
9
|
+
c.bulk_max_attempts = 2
|
|
10
|
+
c.bulk_retryable_error_codes = %i[rate_limited]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "validates messages input type" do
|
|
15
|
+
dispatcher = described_class.new(client: double, configuration: config, sleeper: ->(_seconds) {})
|
|
16
|
+
|
|
17
|
+
expect { dispatcher.deliver("x") }.to raise_error(WhatsAppNotifier::ConfigurationError, /array/)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "validates recipient limit" do
|
|
21
|
+
dispatcher = described_class.new(client: double, configuration: config, sleeper: ->(_seconds) {})
|
|
22
|
+
messages = Array.new(6) { { to: "+1", body: "a" } }
|
|
23
|
+
|
|
24
|
+
expect { dispatcher.deliver(messages) }.to raise_error(WhatsAppNotifier::ConfigurationError, /exceeded/)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "delivers messages and summarizes result counts" do
|
|
28
|
+
client = double
|
|
29
|
+
allow(client).to receive(:deliver).and_return(
|
|
30
|
+
WhatsAppNotifier::Result.new(success: true, provider: :web_automation),
|
|
31
|
+
WhatsAppNotifier::Result.new(success: false, provider: :web_automation, error_code: :blocked)
|
|
32
|
+
)
|
|
33
|
+
dispatcher = described_class.new(client: client, configuration: config, sleeper: ->(_seconds) {})
|
|
34
|
+
|
|
35
|
+
summary = dispatcher.deliver([{ to: "+1", body: "a" }, { to: "+2", body: "b" }])
|
|
36
|
+
|
|
37
|
+
expect(summary[:total]).to eq(2)
|
|
38
|
+
expect(summary[:success]).to eq(1)
|
|
39
|
+
expect(summary[:failed]).to eq(1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "waits on provider wait_seconds and retries retryable failures" do
|
|
43
|
+
slept = []
|
|
44
|
+
client = double
|
|
45
|
+
allow(client).to receive(:deliver).and_return(
|
|
46
|
+
WhatsAppNotifier::Result.new(success: false, provider: :web_automation, error_code: :rate_limited),
|
|
47
|
+
WhatsAppNotifier::Result.new(success: true, provider: :web_automation, wait_seconds: 2)
|
|
48
|
+
)
|
|
49
|
+
dispatcher = described_class.new(client: client, configuration: config, sleeper: ->(seconds) { slept << seconds })
|
|
50
|
+
|
|
51
|
+
summary = dispatcher.deliver([{ to: "+1", body: "a" }])
|
|
52
|
+
|
|
53
|
+
expect(summary[:success]).to eq(1)
|
|
54
|
+
expect(slept).to include(2)
|
|
55
|
+
expect(client).to have_received(:deliver).twice
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "deduplicates idempotency keys within one bulk run" do
|
|
59
|
+
client = double
|
|
60
|
+
allow(client).to receive(:deliver).and_return(WhatsAppNotifier::Result.new(success: true, provider: :web_automation))
|
|
61
|
+
dispatcher = described_class.new(client: client, configuration: config, sleeper: ->(_seconds) {})
|
|
62
|
+
|
|
63
|
+
summary = dispatcher.deliver(
|
|
64
|
+
[
|
|
65
|
+
{ to: "+1", body: "a", idempotency_key: "k1" },
|
|
66
|
+
{ to: "+2", body: "b", idempotency_key: "k1" }
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(summary[:results].last.error_code).to eq(:duplicate_idempotency_key)
|
|
71
|
+
expect(client).to have_received(:deliver).once
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe WhatsAppNotifier::Bulk::RateLimiter do
|
|
4
|
+
it "sleeps with base delay and jitter" do
|
|
5
|
+
slept = []
|
|
6
|
+
limiter = described_class.new(
|
|
7
|
+
base_delay: 1.0,
|
|
8
|
+
jitter: 0.5,
|
|
9
|
+
sleeper: ->(seconds) { slept << seconds },
|
|
10
|
+
rng: Random.new(123)
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
limiter.wait_before_next
|
|
14
|
+
|
|
15
|
+
expect(slept.length).to eq(1)
|
|
16
|
+
expect(slept.first).to be_between(1.0, 1.5)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "does not sleep for non-positive delay" do
|
|
20
|
+
slept = []
|
|
21
|
+
limiter = described_class.new(base_delay: 0, jitter: 0, sleeper: ->(seconds) { slept << seconds })
|
|
22
|
+
|
|
23
|
+
limiter.wait_before_next
|
|
24
|
+
|
|
25
|
+
expect(slept).to be_empty
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe WhatsAppNotifier::Bulk::RetryPolicy do
|
|
4
|
+
it "retries for retryable failures and returns final result" do
|
|
5
|
+
policy = described_class.new(max_attempts: 3, retryable_error_codes: %i[rate_limited])
|
|
6
|
+
attempts = 0
|
|
7
|
+
|
|
8
|
+
result = policy.with_retries do
|
|
9
|
+
attempts += 1
|
|
10
|
+
if attempts < 3
|
|
11
|
+
WhatsAppNotifier::Result.new(success: false, provider: :web_automation, error_code: :rate_limited)
|
|
12
|
+
else
|
|
13
|
+
WhatsAppNotifier::Result.new(success: true, provider: :web_automation)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
expect(attempts).to eq(3)
|
|
18
|
+
expect(result).to be_success
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "does not retry non-retryable failures" do
|
|
22
|
+
policy = described_class.new(max_attempts: 3, retryable_error_codes: %i[rate_limited])
|
|
23
|
+
attempts = 0
|
|
24
|
+
|
|
25
|
+
result = policy.with_retries do
|
|
26
|
+
attempts += 1
|
|
27
|
+
WhatsAppNotifier::Result.new(success: false, provider: :web_automation, error_code: :blocked)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
expect(attempts).to eq(1)
|
|
31
|
+
expect(result).to be_failure
|
|
32
|
+
end
|
|
33
|
+
end
|