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,43 @@
1
+ module WhatsAppNotifier
2
+ class Client
3
+ def initialize(configuration:)
4
+ @configuration = configuration
5
+ @providers = {}
6
+ end
7
+
8
+ def deliver(to:, body:, metadata: {}, provider: nil, idempotency_key: nil)
9
+ payload = {
10
+ to: to,
11
+ body: body,
12
+ metadata: metadata,
13
+ idempotency_key: idempotency_key
14
+ }
15
+ provider_for(provider || @configuration.provider).deliver(payload)
16
+ end
17
+
18
+ def deliver_bulk(messages, provider: nil, sleeper: ->(seconds) { sleep(seconds) }, rng: Random.new)
19
+ Bulk::Dispatcher.new(client: self, configuration: @configuration, sleeper: sleeper, rng: rng).deliver(messages, provider: provider)
20
+ end
21
+
22
+ def scan_qr(metadata: {}, provider: nil)
23
+ provider_for(provider || @configuration.provider).scan_qr(metadata: metadata)
24
+ end
25
+
26
+ def connection_status(metadata: {}, provider: nil)
27
+ provider_for(provider || @configuration.provider).connection_status(metadata: metadata)
28
+ end
29
+
30
+ private
31
+
32
+ def provider_for(key)
33
+ @providers[key] ||= build_provider(key)
34
+ end
35
+
36
+ def build_provider(key)
37
+ provider_key = key.to_sym
38
+ raise ConfigurationError, "unknown provider: #{key.inspect}" unless provider_key == :web_automation
39
+
40
+ Providers::WebAutomation.new(configuration: @configuration)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ require "logger"
2
+
3
+ module WhatsAppNotifier
4
+ class Configuration
5
+ attr_accessor :provider, :web_adapter, :web_session_path,
6
+ :bulk_base_delay_seconds, :bulk_jitter_seconds, :bulk_max_recipients,
7
+ :bulk_max_attempts, :bulk_retryable_error_codes, :logger,
8
+ :web_automation_enabled, :warn_on_risky_provider
9
+
10
+ def initialize
11
+ @provider = :web_automation
12
+ @web_adapter = WebAdapter.new
13
+ @web_session_path = "tmp/whatsapp_notifier/session.json"
14
+ @bulk_base_delay_seconds = 1.0
15
+ @bulk_jitter_seconds = 0.3
16
+ @bulk_max_recipients = 500
17
+ @bulk_max_attempts = 3
18
+ @bulk_retryable_error_codes = %i[rate_limited network_error temporary_failure].freeze
19
+ @logger = Logger.new($stdout)
20
+ @web_automation_enabled = true
21
+ @warn_on_risky_provider = true
22
+ end
23
+
24
+ def validate!
25
+ raise ConfigurationError, "provider is required" if provider.nil?
26
+ raise ConfigurationError, "only :web_automation provider is supported" unless provider.to_sym == :web_automation
27
+ raise ConfigurationError, "bulk_max_recipients must be positive" if bulk_max_recipients.to_i <= 0
28
+ raise ConfigurationError, "bulk_max_attempts must be positive" if bulk_max_attempts.to_i <= 0
29
+ raise ConfigurationError, "web_adapter must be configured and respond to send_message, fetch_qr_code, connection_status" unless valid_web_adapter?
30
+ end
31
+
32
+ private
33
+
34
+ def valid_web_adapter?
35
+ return false unless web_adapter
36
+
37
+ required_methods = %i[send_message fetch_qr_code connection_status]
38
+ required_methods.all? { |method_name| web_adapter.respond_to?(method_name) }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,103 @@
1
+ require "fileutils"
2
+
3
+ module WhatsAppNotifier
4
+ module Doctor
5
+ module_function
6
+
7
+ DEFAULT_PORT = 3001
8
+
9
+ def run(io: $stdout, env: ENV, app_root: Dir.pwd)
10
+ checks = [
11
+ check_bun,
12
+ check_chromium(env: env),
13
+ check_session_dir(env: env, app_root: app_root),
14
+ check_service_url(env: env)
15
+ ]
16
+
17
+ checks.each do |check|
18
+ icon = check[:ok] ? "PASS" : "FAIL"
19
+ io.puts("#{icon}: #{check[:name]}")
20
+ io.puts(" #{check[:message]}")
21
+ end
22
+
23
+ failed = checks.reject { |check| check[:ok] }
24
+ return true if failed.empty?
25
+
26
+ io.puts("")
27
+ io.puts("Quick fixes:")
28
+ failed.each { |check| io.puts("- #{check[:fix]}") if check[:fix] }
29
+ false
30
+ end
31
+
32
+ def session_dir(env: ENV, app_root: Dir.pwd)
33
+ env["WHATSAPP_SESSION_DIR"] || File.expand_path("tmp/whatsapp_notifier/.wwebjs_auth", app_root)
34
+ end
35
+
36
+ def default_service_url
37
+ "http://127.0.0.1:#{DEFAULT_PORT}"
38
+ end
39
+
40
+ def check_bun
41
+ if system("bun --version > /dev/null 2>&1")
42
+ { ok: true, name: "Bun installed", message: "bun is available in PATH." }
43
+ else
44
+ {
45
+ ok: false,
46
+ name: "Bun installed",
47
+ message: "bun is missing.",
48
+ fix: "Install Bun from https://bun.sh then rerun `bundle exec whatsapp_notifier doctor`."
49
+ }
50
+ end
51
+ end
52
+
53
+ def check_chromium(env: ENV)
54
+ executable = env["PUPPETEER_EXECUTABLE_PATH"]
55
+ if executable && File.executable?(executable)
56
+ return { ok: true, name: "Chromium path", message: "Using PUPPETEER_EXECUTABLE_PATH=#{executable}." }
57
+ end
58
+
59
+ common_paths = ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
60
+ found = common_paths.find { |path| File.executable?(path) }
61
+
62
+ if found
63
+ { ok: true, name: "Chromium path", message: "Detected Chromium-compatible executable at #{found}." }
64
+ else
65
+ {
66
+ ok: false,
67
+ name: "Chromium path",
68
+ message: "No Chromium executable found.",
69
+ fix: "Install Chromium/Chrome or set PUPPETEER_EXECUTABLE_PATH=/path/to/chrome."
70
+ }
71
+ end
72
+ end
73
+
74
+ def check_session_dir(env: ENV, app_root: Dir.pwd)
75
+ dir = session_dir(env: env, app_root: app_root)
76
+ FileUtils.mkdir_p(dir)
77
+ File.write(File.join(dir, ".write_test"), "ok")
78
+ File.delete(File.join(dir, ".write_test"))
79
+ { ok: true, name: "Session directory", message: "Writable directory at #{dir}." }
80
+ rescue StandardError => e
81
+ {
82
+ ok: false,
83
+ name: "Session directory",
84
+ message: "Cannot write to #{dir}: #{e.message}",
85
+ fix: "Ensure the directory is writable or set WHATSAPP_SESSION_DIR to a writable path."
86
+ }
87
+ end
88
+
89
+ def check_service_url(env: ENV)
90
+ service_url = env["WHATSAPP_NOTIFIER_SERVICE_URL"] || env["WHATSAPP_SERVICE_URL"] || default_service_url
91
+ if service_url.match?(%r{\Ahttps?://})
92
+ { ok: true, name: "Service URL", message: "Using #{service_url}." }
93
+ else
94
+ {
95
+ ok: false,
96
+ name: "Service URL",
97
+ message: "Invalid URL: #{service_url.inspect}",
98
+ fix: "Set WHATSAPP_NOTIFIER_SERVICE_URL to something like #{default_service_url}."
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,5 @@
1
+ module WhatsAppNotifier
2
+ class Error < StandardError; end
3
+ class ConfigurationError < Error; end
4
+ class DeliveryError < Error; end
5
+ end
@@ -0,0 +1,20 @@
1
+ module WhatsAppNotifier
2
+ module Jobs
3
+ class SendMessageJob
4
+ def self.perform_later(notification_class_name, params)
5
+ raise LoadError, "ActiveJob is required for deliver_later" unless defined?(::ActiveJob::Base)
6
+
7
+ perform_now(notification_class_name, params)
8
+ end
9
+
10
+ def self.perform_now(notification_class_name, params)
11
+ new.perform(notification_class_name, params)
12
+ end
13
+
14
+ def perform(notification_class_name, params)
15
+ klass = notification_class_name.split("::").inject(Object) { |ctx, const| ctx.const_get(const) }
16
+ klass.with(params).deliver_now
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,93 @@
1
+ module WhatsAppNotifier
2
+ class Notification
3
+ class << self
4
+ attr_accessor :default_to, :default_provider, :default_template
5
+
6
+ def with(params = {})
7
+ new(params)
8
+ end
9
+
10
+ def to(value = nil)
11
+ return default_to if value.nil?
12
+ self.default_to = value
13
+ end
14
+
15
+ def provider(value = nil)
16
+ return default_provider if value.nil?
17
+ self.default_provider = value
18
+ end
19
+
20
+ def template(name = nil, body = nil)
21
+ @templates ||= {}
22
+ return default_template if name.nil?
23
+
24
+ self.default_template = name.to_sym
25
+ @templates[name.to_sym] = body if body
26
+ end
27
+
28
+ def templates
29
+ @templates ||= {}
30
+ end
31
+
32
+ def deliver_now(params = {})
33
+ with(params).deliver_now
34
+ end
35
+
36
+ def deliver_later(params = {})
37
+ Jobs::SendMessageJob.perform_later(name, params)
38
+ end
39
+ end
40
+
41
+ attr_reader :params
42
+
43
+ def initialize(params = {})
44
+ @params = params
45
+ end
46
+
47
+ def message
48
+ return render_template if template_name
49
+
50
+ raise NotImplementedError, "#{self.class.name} must implement #message or define template"
51
+ end
52
+
53
+ def to
54
+ params[:to] || self.class.default_to
55
+ end
56
+
57
+ def provider
58
+ params[:provider] || self.class.default_provider
59
+ end
60
+
61
+ def metadata
62
+ params[:metadata] || {}
63
+ end
64
+
65
+ def template_name
66
+ (params[:template] || self.class.default_template)&.to_sym
67
+ end
68
+
69
+ def template_params
70
+ params[:params] || {}
71
+ end
72
+
73
+ def deliver_now
74
+ raise ConfigurationError, "recipient is required" if to.nil? || to.to_s.strip.empty?
75
+
76
+ WhatsAppNotifier.deliver(
77
+ to: to,
78
+ body: message,
79
+ provider: provider,
80
+ metadata: metadata
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def render_template
87
+ body = self.class.templates[template_name]
88
+ raise ConfigurationError, "template not found: #{template_name}" unless body
89
+
90
+ body.gsub(/\{\{(\w+)\}\}/) { |_match| template_params[Regexp.last_match(1).to_sym].to_s }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,24 @@
1
+ module WhatsAppNotifier
2
+ module Providers
3
+ class Base
4
+ attr_reader :configuration
5
+
6
+ def initialize(configuration:)
7
+ @configuration = configuration
8
+ end
9
+
10
+ def deliver(_payload)
11
+ raise NotImplementedError, "#{self.class.name} must implement #deliver"
12
+ end
13
+
14
+ def scan_qr(metadata: {})
15
+ raise NotImplementedError, "#{self.class.name} does not support QR scanning"
16
+ end
17
+
18
+ def connection_status(metadata: {})
19
+ raise NotImplementedError, "#{self.class.name} does not support status checking"
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,85 @@
1
+ module WhatsAppNotifier
2
+ module Providers
3
+ class WebAutomation < Base
4
+ def initialize(configuration:)
5
+ super
6
+ @store = Session::Store.new(path: configuration.web_session_path)
7
+ end
8
+
9
+ def deliver(payload)
10
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
11
+
12
+ adapter = configuration.web_adapter
13
+ raise ConfigurationError, "web_adapter must be configured for web_automation provider" unless adapter.respond_to?(:send_message)
14
+
15
+ warn_risk_once
16
+ session = session_for(payload.fetch(:metadata, {}))
17
+ response = adapter.send_message(payload: payload, session: session)
18
+ persist_session(response.fetch(:session, {}), payload.fetch(:metadata, {}))
19
+
20
+ Result.new(
21
+ success: response.fetch(:success),
22
+ provider: :web_automation,
23
+ message_id: response[:message_id],
24
+ error_code: response[:error_code],
25
+ error_message: response[:error_message],
26
+ wait_seconds: response[:wait_seconds],
27
+ metadata: response.fetch(:metadata, {})
28
+ )
29
+ rescue StandardError => e
30
+ Result.new(success: false, provider: :web_automation, error_code: :delivery_exception, error_message: e.message)
31
+ end
32
+
33
+ def scan_qr(metadata: {})
34
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
35
+
36
+ adapter = configuration.web_adapter
37
+ raise ConfigurationError, "web_adapter must be configured for web_automation provider" unless adapter.respond_to?(:fetch_qr_code)
38
+
39
+ Session::QrService.new(store: @store, adapter: adapter).qr_code(metadata: metadata)
40
+ end
41
+
42
+ def connection_status(metadata: {})
43
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
44
+
45
+ adapter = configuration.web_adapter
46
+ raise ConfigurationError, "web_adapter must be configured for web_automation provider" unless adapter.respond_to?(:connection_status)
47
+
48
+ adapter.connection_status(metadata: metadata)
49
+ end
50
+
51
+
52
+ private
53
+
54
+ def session_for(metadata)
55
+ user_id = metadata[:user_id]
56
+ return @store.load unless user_id
57
+
58
+ all_sessions = @store.load
59
+ all_sessions.fetch(:users, {}).fetch(user_key(user_id), {})
60
+ end
61
+
62
+ def persist_session(next_session, metadata)
63
+ user_id = metadata[:user_id]
64
+ return @store.save(next_session) unless user_id
65
+
66
+ all_sessions = @store.load
67
+ users = all_sessions.fetch(:users, {})
68
+ users[user_key(user_id)] = next_session
69
+ @store.save(all_sessions.merge(users: users))
70
+ end
71
+
72
+ def user_key(user_id)
73
+ user_id.to_s.to_sym
74
+ end
75
+
76
+ def warn_risk_once
77
+ return unless configuration.warn_on_risky_provider
78
+ return if defined?(@risk_warned) && @risk_warned
79
+
80
+ configuration.logger.warn("web_automation provider uses WhatsApp Web automation. Use responsibly and follow WhatsApp policies.")
81
+ @risk_warned = true
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,14 @@
1
+ module WhatsAppNotifier
2
+ class Railtie < ::Rails::Railtie
3
+ config.whatsapp_notifier = {}
4
+
5
+ initializer "whatsapp_notifier.configure" do |app|
6
+ WhatsAppNotifier.configure do |config|
7
+ app.config.whatsapp_notifier.each do |key, value|
8
+ setter = "#{key}="
9
+ config.public_send(setter, value) if config.respond_to?(setter)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ module WhatsAppNotifier
2
+ class Result
3
+ attr_reader :success, :provider, :message_id, :error_code, :error_message, :wait_seconds, :metadata
4
+
5
+ def initialize(success:, provider:, message_id: nil, error_code: nil, error_message: nil, wait_seconds: nil, metadata: {})
6
+ @success = success
7
+ @provider = provider
8
+ @message_id = message_id
9
+ @error_code = error_code
10
+ @error_message = error_message
11
+ @wait_seconds = wait_seconds
12
+ @metadata = metadata
13
+ end
14
+
15
+ def success?
16
+ success
17
+ end
18
+
19
+ def failure?
20
+ !success?
21
+ end
22
+ end
23
+ end