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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +7 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +126 -0
  5. data/Rakefile +6 -0
  6. data/bin/whatsapp_notifier +53 -0
  7. data/docs/bulk_messaging_policy.md +30 -0
  8. data/docs/graphify.md +108 -0
  9. data/docs/rails_setup.md +57 -0
  10. data/examples/notification_example.rb +14 -0
  11. data/lib/generators/whatsapp_notifier/install_generator.rb +60 -0
  12. data/lib/generators/whatsapp_notifier/install_service_generator.rb +33 -0
  13. data/lib/generators/whatsapp_notifier/templates/whatsapp_notifier.rb +6 -0
  14. data/lib/whatsapp_notifier/bulk/dispatcher.rb +64 -0
  15. data/lib/whatsapp_notifier/bulk/rate_limiter.rb +17 -0
  16. data/lib/whatsapp_notifier/bulk/retry_policy.rb +32 -0
  17. data/lib/whatsapp_notifier/client.rb +43 -0
  18. data/lib/whatsapp_notifier/configuration.rb +41 -0
  19. data/lib/whatsapp_notifier/doctor.rb +103 -0
  20. data/lib/whatsapp_notifier/errors.rb +5 -0
  21. data/lib/whatsapp_notifier/jobs/send_message_job.rb +20 -0
  22. data/lib/whatsapp_notifier/notification.rb +93 -0
  23. data/lib/whatsapp_notifier/providers/base.rb +24 -0
  24. data/lib/whatsapp_notifier/providers/web_automation.rb +85 -0
  25. data/lib/whatsapp_notifier/railtie.rb +14 -0
  26. data/lib/whatsapp_notifier/result.rb +23 -0
  27. data/lib/whatsapp_notifier/services/web_automation/bun.lock +452 -0
  28. data/lib/whatsapp_notifier/services/web_automation/index.ts +285 -0
  29. data/lib/whatsapp_notifier/services/web_automation/package.json +14 -0
  30. data/lib/whatsapp_notifier/session/qr_service.rb +51 -0
  31. data/lib/whatsapp_notifier/session/store.rb +22 -0
  32. data/lib/whatsapp_notifier/version.rb +4 -0
  33. data/lib/whatsapp_notifier/web_adapter.rb +72 -0
  34. data/lib/whatsapp_notifier.rb +72 -0
  35. data/spec/bulk/dispatcher_spec.rb +73 -0
  36. data/spec/bulk/rate_limiter_spec.rb +27 -0
  37. data/spec/bulk/retry_policy_spec.rb +33 -0
  38. data/spec/client_spec.rb +52 -0
  39. data/spec/configuration_spec.rb +47 -0
  40. data/spec/doctor_spec.rb +46 -0
  41. data/spec/jobs/send_message_job_spec.rb +36 -0
  42. data/spec/notification_spec.rb +60 -0
  43. data/spec/providers/base_spec.rb +17 -0
  44. data/spec/providers/web_automation_spec.rb +109 -0
  45. data/spec/railtie_spec.rb +37 -0
  46. data/spec/result_spec.rb +12 -0
  47. data/spec/session/qr_service_spec.rb +42 -0
  48. data/spec/session/store_spec.rb +21 -0
  49. data/spec/spec_helper.rb +17 -0
  50. data/spec/web_adapter_spec.rb +55 -0
  51. data/spec/whatsapp_notifier_spec.rb +102 -0
  52. 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,4 @@
1
+ module WhatsAppNotifier
2
+ VERSION = "0.2.0"
3
+
4
+ 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