waha-ruby 0.1.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.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,53 @@
1
+ # Contributing
2
+
3
+ Contributions must keep the public API explicit, framework-neutral, and compatible with the declared v0.1 contract. Do not add runtime dependencies without a concrete API need.
4
+
5
+ ## Setup
6
+
7
+ This repository uses [asdf](https://asdf-vm.com/) and `.tool-versions`.
8
+
9
+ ```bash
10
+ asdf install
11
+ ruby --version
12
+ bundle install
13
+ ```
14
+
15
+ `bin/setup` is a convenience wrapper for `bundle install`.
16
+
17
+ Run focused tests while working:
18
+
19
+ ```bash
20
+ bundle exec rspec spec/path/to/spec.rb
21
+ ```
22
+
23
+ Before submitting, run the complete repository gate:
24
+
25
+ ```bash
26
+ bin/ci
27
+ ```
28
+
29
+ `bin/ci` runs RSpec, RuboCop, Reek on `lib`, Flay with mass threshold 150 on `lib`, and Bundler Audit. Do not add exclusions to silence warnings in new code. Fix the design or update a rule only when it reflects an established project convention.
30
+
31
+ Use double-quoted Ruby strings and frozen string literals. Keep changes within the owned surface for the task; runtime API and API specs require their own focused review.
32
+
33
+ ## Documentation
34
+
35
+ Update `README.md` for concise user-facing navigation and `docs/api.md` for signatures, return behavior, configuration, and security details. Do not copy upstream WAHA code. Link to the official Apache-2.0 project instead.
36
+
37
+ ## Release setup
38
+
39
+ RubyGems trusted publishing requires no long-lived token:
40
+
41
+ 1. Create a GitHub environment named `rubygems` with no secrets.
42
+ 2. Add a pending trusted publisher for gem `waha-ruby` on RubyGems with owner `develoz-com`, repository `waha-ruby`, workflow `release.yml`, and environment `rubygems`.
43
+ 3. Keep the repository's release workflow pinned to the reviewed action SHAs.
44
+
45
+ ## Release process
46
+
47
+ 1. Bump `Waha::VERSION` in `lib/waha/version.rb` and add user-facing changes under `Unreleased` in `CHANGELOG.md`.
48
+ 2. Run `bin/ci` and review the package contents with `gem build waha-ruby.gemspec`.
49
+ 3. Merge to `main`.
50
+ 4. Publish a stable GitHub Release tagged `vX.Y.Z` at the current `main` commit.
51
+ 5. Let `release.yml` validate the tag, build and inspect the exact gem, publish through RubyGems OIDC, verify API visibility/SHA, and finalize `CHANGELOG.md`.
52
+
53
+ Prereleases are not supported. If publication succeeds but changelog finalization fails, rerun the same workflow; do not create another tag or publish a second version.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ Copyright (c) 2026 Mauricio Zaffari, Develoz
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # waha-ruby
2
+
3
+ Framework-neutral Ruby client for the [WAHA](https://waha.dev/) WhatsApp HTTP API. It provides resource-oriented access to sessions, messages, media, chats, contacts, presence, webhooks, and explicit GOWS helpers, with an opt-in Rails adapter.
4
+
5
+ > `waha-ruby` is an independent community project, not an official WAHA project. WAHA is documented at [waha.dev](https://waha.dev/) and developed at [devlikeapro/waha](https://github.com/devlikeapro/waha). This gem links to the Apache-2.0 upstream project and does not copy its code.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 4.0.5 or newer
10
+ - WAHA v2025.9 or a compatible WAHA HTTP API deployment
11
+ - An API key when the WAHA deployment requires one
12
+
13
+ The gem is tested against the declared v0.1 API surface. WAHA server versions can add or change response fields; the client returns parsed provider JSON without imposing a domain model.
14
+
15
+ ## Installation
16
+
17
+ Add the gem to your application:
18
+
19
+ ```ruby
20
+ gem "waha-ruby"
21
+ ```
22
+
23
+ Then run `bundle install`. The only runtime dependency is `faraday`.
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ require "waha"
29
+
30
+ client = Waha::Client.new(
31
+ base_url: ENV.fetch("WAHA_BASE_URL"),
32
+ api_key: ENV["WAHA_API_KEY"],
33
+ session: ENV.fetch("WAHA_SESSION", "default")
34
+ )
35
+
36
+ client.sessions.list
37
+ client.messages.send_text(chat_id: "5511999999999@c.us", text: "Olá")
38
+ ```
39
+
40
+ Every request uses the client's default session unless a resource method accepts an explicit `session:` override. Keep credentials in environment variables or a secret manager; do not commit them.
41
+
42
+ ## API guide
43
+
44
+ The complete v0.1 resource map, argument conventions, raw return shapes, file payloads, errors, GOWS helpers, webhook verification, and Rails integration are in [docs/api.md](docs/api.md). For setup and secret handling, see [docs/installation.md](docs/installation.md).
45
+
46
+ ## Security
47
+
48
+ The client sends `X-Api-Key` to WAHA and never logs credentials. Error details are bounded and redacted before they are placed in `Waha::Error` messages. Treat raw response hashes as untrusted provider data and redact message text, phone numbers, media URLs, and API keys before logging or persisting them. Webhook verification must run against the exact raw request body before JSON parsing.
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ asdf install
54
+ bundle install
55
+ bundle exec rspec path/to/spec.rb
56
+ bin/ci
57
+ ```
58
+
59
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for quality rules and releases. See [CHANGELOG.md](CHANGELOG.md) for changes.
60
+
61
+ ## License
62
+
63
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ desc "Run RuboCop linter"
9
+ task :rubocop do
10
+ sh "bundle exec rubocop"
11
+ end
12
+
13
+ desc "Run CI checks (RSpec, RuboCop, Reek, Flay, and Bundler Audit)"
14
+ task :ci do
15
+ sh "bin/ci"
16
+ end
17
+
18
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Waha
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_initializer
11
+ copy_file "waha.rb", "config/initializers/waha.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep WAHA credentials in the application environment. The factory creates a
4
+ # fresh client for each caller, so session-specific state is never shared.
5
+ Rails.application.config.waha.client_factory = lambda do |session: ENV.fetch("WAHA_SESSION")|
6
+ Waha::Client.new(
7
+ base_url: ENV.fetch("WAHA_BASE_URL"),
8
+ api_key: ENV.fetch("WAHA_API_KEY"),
9
+ session: session
10
+ )
11
+ end
12
+
13
+ # Used by Waha::Rails::ControllerConcern#verify_waha_webhook!.
14
+ Rails.application.config.waha.webhook_secret = -> { ENV.fetch("WAHA_WEBHOOK_HMAC_KEY") }
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ class Client
5
+ RESOURCES = {
6
+ sessions: Resources::Sessions,
7
+ messages: Resources::Messages,
8
+ chats: Resources::Chats,
9
+ contacts: Resources::Contacts,
10
+ presence: Resources::Presence,
11
+ webhooks: Resources::Webhooks,
12
+ media: Resources::Media
13
+ }.freeze
14
+
15
+ def initialize(base_url:, api_key:, session: nil, timeout: 30, transport: nil)
16
+ @base_url = base_url
17
+ @session = session
18
+ @transport = transport || Transport::Faraday.new(base_url:, api_key:, timeout:)
19
+ @resources = {}
20
+ end
21
+
22
+ RESOURCES.each do |name, resource_class|
23
+ define_method(name) do
24
+ @resources[name] ||= build_resource(name, resource_class)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def build_resource(name, resource_class)
31
+ return resource_class.new(transport: @transport, base_url: @base_url) if name == :media
32
+ return resource_class.new(transport: @transport, session: @session, sessions:) if name == :webhooks
33
+
34
+ resource_class.new(transport: @transport, session: @session)
35
+ end
36
+ end
37
+ end
data/lib/waha/error.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ class Error < StandardError
5
+ MESSAGE_LIMIT = 600
6
+ DETAILS_LIMIT = 450
7
+
8
+ attr_reader :operation, :status, :details
9
+
10
+ def initialize(operation:, status: nil, details: nil)
11
+ @operation = operation.to_s.slice(0, 80)
12
+ @status = status
13
+ @details = sanitize(details)
14
+ super(build_message.slice(0, MESSAGE_LIMIT))
15
+ end
16
+
17
+ private
18
+
19
+ def sanitize(details)
20
+ text = details.to_s
21
+ text = "operation failed" if text.empty?
22
+ text.gsub(%r{data:[^\s,;]+;base64,[A-Za-z0-9+/=]+}i, "[REDACTED]")
23
+ .slice(0, DETAILS_LIMIT)
24
+ end
25
+
26
+ def build_message
27
+ status_text = status.nil? ? "unknown" : status
28
+ "WAHA #{operation} failed (status #{status_text}): #{details}"
29
+ end
30
+ end
31
+
32
+ class ApiError < Error; end
33
+ class TransportError < Error; end
34
+ class ValidationError < Error; end
35
+ class VerificationError < Error; end
36
+ end
data/lib/waha/gows.rb ADDED
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Gows
5
+ DIRECT_CHAT_ID_PATTERN = /\A\d+@c\.us\z/
6
+
7
+ module MessageId
8
+ RAW_PATTERN = /\A3EB0[0-9A-F]{18}\z/
9
+ CANONICAL_PATTERN = /\Atrue_[^\s]+_3EB0[0-9A-F]{18}\z/
10
+
11
+ module_function
12
+
13
+ def valid_raw?(raw_id)
14
+ raw_id.is_a?(String) && RAW_PATTERN.match?(raw_id)
15
+ end
16
+
17
+ def canonical(chat_id:, raw_id:)
18
+ unless chat_id.is_a?(String) && !chat_id.empty?
19
+ raise ValidationError.new(operation: "canonical_message_id", details: "chat_id is required")
20
+ end
21
+ unless valid_raw?(raw_id)
22
+ raise ValidationError.new(operation: "canonical_message_id", details: "raw_id must be a valid GOWS id")
23
+ end
24
+
25
+ "true_#{chat_id}_#{raw_id}"
26
+ end
27
+
28
+ def valid_canonical?(message_id)
29
+ return false unless message_id.is_a?(String)
30
+ return false unless CANONICAL_PATTERN.match?(message_id)
31
+
32
+ raw_id = message_id.to_s.split("_").last
33
+ valid_raw?(raw_id)
34
+ end
35
+ end
36
+
37
+ module_function
38
+
39
+ def valid_direct_chat_id?(chat_id)
40
+ chat_id.is_a?(String) && DIRECT_CHAT_ID_PATTERN.match?(chat_id)
41
+ end
42
+
43
+ class EditResponseValidator
44
+ TARGET_ID_LENGTH = 22
45
+
46
+ EDIT_MISSING_MESSAGE = "edit protocol response missing"
47
+ TARGET_MISMATCH_MESSAGE = "edit protocol response targeted another message"
48
+ TEXT_MISMATCH_MESSAGE = "edit protocol response text mismatch"
49
+
50
+ def initialize(parsed_response:, message_id:, text:, status: nil)
51
+ @parsed_response = parsed_response
52
+ @message_id = message_id
53
+ @text = text
54
+ @status = status
55
+ end
56
+
57
+ def validate!
58
+ protocol_message = extract_protocol_message
59
+ raise_error!(EDIT_MISSING_MESSAGE) unless protocol_message.is_a?(Hash)
60
+
61
+ raise_error!(TARGET_MISMATCH_MESSAGE) unless target_matches?(protocol_message)
62
+ raise_error!(TEXT_MISMATCH_MESSAGE) unless text_matches?(protocol_message)
63
+
64
+ @parsed_response
65
+ end
66
+
67
+ private
68
+
69
+ def extract_protocol_message
70
+ data = hash_value(@parsed_response, "_data")
71
+ message = hash_value(data, "Message")
72
+ raw_message = hash_value(data, "RawMessage")
73
+
74
+ hash_value(message, "protocolMessage") || hash_value(raw_message, "protocolMessage")
75
+ end
76
+
77
+ def target_matches?(protocol_message)
78
+ target_id = nested_value(protocol_message, "key", "ID")
79
+ target_id.is_a?(String) &&
80
+ target_id.length == TARGET_ID_LENGTH &&
81
+ @message_id.to_s.end_with?(target_id)
82
+ end
83
+
84
+ def text_matches?(protocol_message)
85
+ nested_value(protocol_message, "editedMessage", "extendedTextMessage", "text") == @text
86
+ end
87
+
88
+ def nested_value(value, *keys)
89
+ keys.reduce(value) { |nested, key| hash_value(nested, key) }
90
+ end
91
+
92
+ def hash_value(value, key)
93
+ return unless value.is_a?(Hash)
94
+
95
+ value[key] || value[key.to_sym]
96
+ end
97
+
98
+ def raise_error!(details)
99
+ raise ApiError.new(operation: "edit_message", status: @status, details:)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Waha
6
+ module Rails
7
+ module ControllerConcern
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ def verify_waha_webhook!
13
+ raw_body = request.body.read
14
+ Waha::Webhook.verify!(
15
+ body: raw_body,
16
+ signature: request.headers["X-Webhook-Hmac"],
17
+ algorithm: request.headers["X-Webhook-Hmac-Algorithm"],
18
+ secret: waha_webhook_secret
19
+ )
20
+ ensure
21
+ request.body.rewind
22
+ end
23
+
24
+ def waha_webhook_secret
25
+ configured_secret = ::Rails.application.config.waha.webhook_secret
26
+ configured_secret.respond_to?(:call) ? configured_secret.call : configured_secret
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/ordered_options"
4
+
5
+ module Waha
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ config.waha = ActiveSupport::OrderedOptions.new
9
+ end
10
+ end
11
+ end
data/lib/waha/rails.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "waha"
4
+ require "active_support"
5
+ require "rails/railtie"
6
+ require_relative "rails/controller_concern"
7
+ require_relative "rails/railtie"
8
+ require_relative "../generators/waha/install/install_generator"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ class Resource
5
+ def initialize(transport:, session:)
6
+ @transport = transport
7
+ @session = session
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :transport
13
+
14
+ def session_name(override, operation)
15
+ value = override || @session
16
+ return value unless value.nil? || value.to_s.empty?
17
+
18
+ raise ValidationError.new(operation:, details: "session is required")
19
+ end
20
+
21
+ def segment(value)
22
+ transport.escape_path_segment(value)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Resources
5
+ class Chats < Resource
6
+ def overview(ids: nil, limit: nil, offset: nil, session: nil, **query)
7
+ name = session_name(session, "chats_overview")
8
+ transport.request(
9
+ method: :get,
10
+ path: "/api/#{segment(name)}/chats/overview",
11
+ operation: "chats_overview",
12
+ expected_status: 200,
13
+ query: Support.query_hash(Support.compact_hash(query.merge(ids:, limit:, offset:)))
14
+ )
15
+ end
16
+
17
+ def messages(chat_id:, limit: 100, offset: nil, session: nil, **query)
18
+ name = session_name(session, "chat_messages")
19
+ transport.request(
20
+ method: :get,
21
+ path: "/api/#{segment(name)}/chats/#{segment(chat_id)}/messages",
22
+ operation: "chat_messages",
23
+ expected_status: 200,
24
+ query: Support.query_hash(Support.compact_hash(query.merge(limit:, offset:)))
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Resources
5
+ class Contacts < Resource
6
+ def list(session: nil, **query)
7
+ transport.request(
8
+ method: :get,
9
+ path: "/api/contacts/all",
10
+ operation: "list_contacts",
11
+ expected_status: 200,
12
+ query: Support.compact_hash(query.merge(session: session_name(session, "list_contacts")))
13
+ )
14
+ end
15
+
16
+ def get(contact_id:, session: nil)
17
+ transport.request(
18
+ method: :get,
19
+ path: "/api/contacts",
20
+ operation: "get_contact",
21
+ expected_status: 200,
22
+ query: { session: session_name(session, "get_contact"), contactId: contact_id }
23
+ )
24
+ end
25
+
26
+ def check_exists(phone:, session: nil)
27
+ transport.request(
28
+ method: :get,
29
+ path: "/api/contacts/check-exists",
30
+ operation: "check_contact_exists",
31
+ expected_status: 200,
32
+ query: { session: session_name(session, "check_contact_exists"), phone: }
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Waha
6
+ module Resources
7
+ class Media < Resource
8
+ DOWNLOAD_TIMEOUT = 60
9
+
10
+ def initialize(transport:, base_url:)
11
+ super(transport:, session: nil)
12
+ @base_url = base_url
13
+ end
14
+
15
+ def download(url:)
16
+ transport.request(
17
+ method: :get,
18
+ path: resolved_url(url),
19
+ operation: "download_media",
20
+ expected_status: 200,
21
+ headers: { "Content-Type" => nil },
22
+ timeout: DOWNLOAD_TIMEOUT,
23
+ response: :binary
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def resolved_url(url)
30
+ uri = parse_uri(url)
31
+
32
+ unless uri.absolute?
33
+ raise ValidationError.new(operation: "download_media", details: "media URL must be absolute")
34
+ end
35
+ unless uri.is_a?(URI::HTTP)
36
+ raise ValidationError.new(operation: "download_media", details: "unsupported media URL scheme")
37
+ end
38
+
39
+ return uri.to_s unless uri.host == "localhost"
40
+
41
+ rebase_to_base_url(uri)
42
+ end
43
+
44
+ def rebase_to_base_url(uri)
45
+ base_uri = URI.parse(@base_url)
46
+ uri.scheme = base_uri.scheme
47
+ uri.host = base_uri.host
48
+ uri.port = base_uri.port
49
+ uri.to_s
50
+ end
51
+
52
+ def parse_uri(url)
53
+ text = url.to_s
54
+ uri = URI.parse(text)
55
+ raise URI::InvalidURIError if text.include?("//") && uri.host.to_s.empty?
56
+
57
+ uri
58
+ rescue URI::InvalidURIError
59
+ raise ValidationError.new(operation: "download_media", details: "invalid media URL")
60
+ end
61
+ end
62
+ end
63
+ end