ask-channel-providers 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '008b3dc3ffd6f0875b93b698a46aa92c490818026cb5218be7247c11b5da1291'
4
- data.tar.gz: 61d0f30d95c6c99f72de845ecd6afa9f708d178774c4145433c73a6d98e882c5
3
+ metadata.gz: 4aee83f6a3689cd90be057aa4c2fbe60be6ed9b2d9d03b982f068dd078da478e
4
+ data.tar.gz: 92375f391036782135a573601491cb94058292707598e2c6cea1a0c26838822d
5
5
  SHA512:
6
- metadata.gz: 2473452ef5244df0a0c1706e8ad39cff6ff1f319c871a3e1f37d89cbfab363d9ebbcc201f3c050f2c7730990915b569f9eb45b5269cfd49586136e3dc1e461a0
7
- data.tar.gz: edb38dff75b2b7a0e8b4a853ec8f7034f9a8dc8c4d9bab5b17cdca98bb2f15aee464d73e9711f39813d4c674c92943f83b5fa1a68ca2679fbcf76e536a87f9ad
6
+ metadata.gz: db379ca378c54dca7136fbeab036b3467afbc098ab37615899c9d4bccc4624a5008df4141d71f4f4639e7ea413dfd5d190745e18fb49c41b04560cb9f1e3a3f4
7
+ data.tar.gz: 3f4e8ed7b481c99aded1184c20af5af9a51a5a060bf9bef4768dcd532fb2ef343806e6ecc9849dde9251dac1f449db1f1fb47af2903f787383871c8a2959adce
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ following the keep-a-changelog format.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.0] - 2026-09-13
9
+
10
+ ### Added
11
+
12
+ - `Ask::ChannelProviders::WebhookAdapter` — base class for webhook-driven
13
+ channels where the provider pushes to your HTTP endpoint: class-level
14
+ `.provider`, `.verify_signature`, `.challenge`, `.parse`, and
15
+ instance-level `#deliver`/`#mark_read`.
16
+ - `Ask::ChannelProviders::InboundMessage` — one normalized inbound message
17
+ shape across providers, with JSON-safe `#to_h`/`.from_h` for background
18
+ job serialization.
19
+ - `Ask::ChannelProviders::WhatsApp` — WhatsApp Cloud API (Meta Graph API,
20
+ direct): X-Hub-Signature-256 verification, webhook challenge, payload
21
+ parsing, and a client that sends chunked text messages and read receipts.
22
+ - Inbound media: `InboundMessage` carries `media_id`/`media_type`, and the
23
+ WhatsApp client downloads attachments (`#fetch_media`) through the Graph
24
+ API.
25
+
8
26
  ## [0.1.0] - 2026-09-04
9
27
 
10
28
  ### Added
data/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  Channel adapters for messaging platforms in the ask-rb ecosystem. It defines
6
6
  a uniform adapter interface for receiving and sending messages, approvals,
7
- and rich cards, plus a cross-platform card system. Only Telegram is
8
- implemented today; other platforms can be added by subclassing the adapter.
7
+ and rich cards, plus a cross-platform card system. Telegram (polling) and
8
+ WhatsApp Cloud API (webhooks) are implemented; other platforms can be added
9
+ by subclassing the adapter.
9
10
 
10
11
  ## Installation
11
12
 
@@ -30,15 +31,51 @@ adapter.start do |msg|
30
31
  end
31
32
  ```
32
33
 
34
+ ## WhatsApp (webhooks)
35
+
36
+ WhatsApp is webhook-driven: the provider POSTs to your endpoint, so
37
+ verification and parsing are class-level and stateless, while delivery is
38
+ bound to one phone number. Secrets are passed in — the gem never reads
39
+ global config — so one process can serve many accounts.
40
+
41
+ ```ruby
42
+ adapter_class = Ask::ChannelProviders::WhatsApp::Adapter
43
+
44
+ # GET /webhooks/whatsapp — registration challenge
45
+ challenge = adapter_class.challenge(params, verify_token: ENV["WHATSAPP_VERIFY_TOKEN"])
46
+
47
+ # POST /webhooks/whatsapp — verify, then parse
48
+ ok = adapter_class.verify_signature(
49
+ raw_body: request.body.read,
50
+ signature: request.headers["X-Hub-Signature-256"],
51
+ secret: ENV["WHATSAPP_APP_SECRET"]
52
+ )
53
+ messages = adapter_class.parse(JSON.parse(raw_body)) # => [InboundMessage]
54
+
55
+ # Reply for one configured number
56
+ adapter = adapter_class.new(phone_number_id: "123", access_token: "EAAG...")
57
+ adapter.deliver(to: "254712345678", text: "Karibu! How can we help?")
58
+ adapter.mark_read(message_id: "wamid....")
59
+ ```
60
+
33
61
  ## Key entry points
34
62
 
35
63
  - `Ask::ChannelProviders::Adapter` - base class for channel adapters.
36
64
  Implement `start(config:, &on_message)` (the callback receives
37
65
  `{ chat_id:, user_id:, text:, session_key: }`), `stop`, `send_message`,
38
66
  `edit_message`, `request_approval`, and `send_card`.
67
+ - `Ask::ChannelProviders::WebhookAdapter` - base class for webhook-driven
68
+ channels: implement `.provider`, `.verify_signature`, `.parse`, and
69
+ `#deliver`; `.challenge` and `#mark_read` have safe defaults.
70
+ - `Ask::ChannelProviders::InboundMessage` - the normalized inbound message
71
+ (`provider`, `external_uid`, `channel_id`, `message_id`, `kind`, `text`,
72
+ `name`, `timestamp`).
39
73
  - `Ask::ChannelProviders::Telegram::Adapter` - the Telegram implementation,
40
74
  backed by the polling-based `Telegram::Bot`. Config: `token`,
41
75
  `allowed_users`, `allowed_chats`.
76
+ - `Ask::ChannelProviders::WhatsApp::Adapter` / `::Client` - the WhatsApp
77
+ Cloud API implementation (Meta Graph API, direct): webhook verification
78
+ and parsing, chunked text sends, read receipts.
42
79
  - `Ask::ChannelProviders::Card` - a structured UI element each adapter
43
80
  renders to its native format. Build cards with `section`, `text`,
44
81
  `button(label, callback:, url:)`, `table(header:, rows:)`, and `divider`.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ # One inbound message, normalized across webhook channels. Adapters
6
+ # parse their provider's payload into these; everything downstream
7
+ # (routing, persistence, agents) works with this shape only.
8
+ #
9
+ # +channel_id+ identifies the receiving account on the provider (the
10
+ # WhatsApp phone number id, the Telegram bot id, ...); +external_uid+
11
+ # identifies the sender on the provider. +media_id+ and +media_type+
12
+ # carry the provider's handle for an attachment (image, voice note,
13
+ # document) so the consumer can download it.
14
+ class InboundMessage
15
+ ATTRIBUTES = %i[provider external_uid channel_id message_id kind text name timestamp media_id media_type].freeze
16
+
17
+ attr_reader(*ATTRIBUTES)
18
+
19
+ def initialize(provider:, external_uid:, channel_id:, message_id:, kind: "text", text: nil, name: nil, timestamp: nil, media_id: nil, media_type: nil)
20
+ @provider = provider
21
+ @external_uid = external_uid
22
+ @channel_id = channel_id
23
+ @message_id = message_id
24
+ @kind = kind
25
+ @text = text
26
+ @name = name
27
+ @timestamp = timestamp
28
+ @media_id = media_id
29
+ @media_type = media_type
30
+ end
31
+
32
+ def media?
33
+ !media_id.to_s.strip.empty?
34
+ end
35
+
36
+ def text?
37
+ kind.to_s == "text" && !text.to_s.strip.empty?
38
+ end
39
+
40
+ # JSON-safe for background-job serialization.
41
+ def to_h
42
+ ATTRIBUTES.to_h { |attribute| [attribute.to_s, public_send(attribute)] }
43
+ end
44
+
45
+ def self.from_h(hash)
46
+ attributes = hash.to_h.transform_keys(&:to_sym)
47
+ new(**ATTRIBUTES.to_h { |attribute| [attribute, attributes[attribute]] })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module ChannelProviders
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ # Base class for webhook-driven channels: the provider pushes messages
6
+ # to your HTTP endpoint, so there is no local polling loop to start.
7
+ #
8
+ # The inbound edge is class-level — signature verification and parsing
9
+ # run before any channel configuration is known, and the receiving
10
+ # account is resolved from the parsed payload. The outbound edge is
11
+ # instance-level, bound to one configured account.
12
+ #
13
+ # Subclasses implement .provider, .verify_signature, .parse, and
14
+ # #deliver; .challenge and #mark_read have safe defaults.
15
+ class WebhookAdapter < Adapter
16
+ # The provider slug ("whatsapp", "telegram", ...).
17
+ def self.provider
18
+ raise NotImplementedError, "#{name} must implement .provider"
19
+ end
20
+
21
+ # Verifies the provider's signature over the raw request body.
22
+ #
23
+ # @param raw_body [String] the unmodified request body
24
+ # @param signature [String] the provider's signature header value
25
+ # @param secret [String] the app secret shared with the provider
26
+ # @return [Boolean]
27
+ def self.verify_signature(raw_body:, signature:, secret:)
28
+ raise NotImplementedError, "#{name} must implement .verify_signature"
29
+ end
30
+
31
+ # Answers the provider's GET webhook-registration challenge.
32
+ #
33
+ # @param params [Hash] the request query parameters
34
+ # @param verify_token [String] the token configured with the provider
35
+ # @return [String, nil] the challenge to echo, or nil when invalid
36
+ def self.challenge(params, verify_token:)
37
+ nil
38
+ end
39
+
40
+ # Parses a webhook payload into messages.
41
+ #
42
+ # @param payload [Hash] the parsed JSON body
43
+ # @return [Array<InboundMessage>]
44
+ def self.parse(payload)
45
+ raise NotImplementedError, "#{name} must implement .parse"
46
+ end
47
+
48
+ # Webhook channels receive through their HTTP endpoint; there is no
49
+ # local loop. No-op keeps the uniform Adapter interface honest.
50
+ def start(config: {}, &on_message)
51
+ nil
52
+ end
53
+
54
+ def stop
55
+ nil
56
+ end
57
+
58
+ # Delivers a text reply to +to+ (the provider's user/chat id).
59
+ #
60
+ # @return [String, nil] the provider's message id
61
+ def deliver(to:, text:)
62
+ raise NotImplementedError, "#{self.class} must implement #deliver"
63
+ end
64
+
65
+ # Marks an inbound message as read where the provider supports it.
66
+ def mark_read(message_id:)
67
+ nil
68
+ end
69
+
70
+ # The uniform Adapter interface delegates to #deliver.
71
+ def send_message(chat_id, text)
72
+ deliver(to: chat_id, text: text)
73
+ end
74
+
75
+ # Most business messaging APIs cannot edit a sent message (WhatsApp
76
+ # cannot at all), so this stays unsupported rather than lying.
77
+ def edit_message(chat_id, message_id, text)
78
+ raise NotImplementedError, "#{self.class} does not support editing messages"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Ask
6
+ module ChannelProviders
7
+ module WhatsApp
8
+ # The WhatsApp Cloud API adapter: verifies Meta's X-Hub-Signature-256
9
+ # over the raw webhook body, parses payloads into InboundMessages, and
10
+ # delivers replies for one configured phone number.
11
+ #
12
+ # Secrets are passed in (never read from globals) so the same adapter
13
+ # serves many accounts in one process.
14
+ class Adapter < WebhookAdapter
15
+ SIGNATURE_PREFIX = "sha256="
16
+
17
+ class << self
18
+ def provider
19
+ "whatsapp"
20
+ end
21
+
22
+ # Meta signs every webhook POST with HMAC-SHA256 of the raw body,
23
+ # keyed by the Meta app secret: "sha256=<hex>".
24
+ def verify_signature(raw_body:, signature:, secret:)
25
+ return false if secret.to_s.empty? || signature.to_s.empty?
26
+
27
+ expected = "#{SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)}"
28
+ secure_compare(expected, signature.to_s)
29
+ end
30
+
31
+ # The GET challenge Meta sends when the webhook URL is registered.
32
+ def challenge(params, verify_token:)
33
+ return unless params["hub.mode"] == "subscribe"
34
+ return if verify_token.to_s.empty?
35
+ return unless secure_compare(params["hub.verify_token"].to_s, verify_token.to_s)
36
+
37
+ params["hub.challenge"]
38
+ end
39
+
40
+ def parse(payload)
41
+ Array(payload["entry"]).flat_map { |entry| Array(entry["changes"]) }.flat_map do |change|
42
+ value = change["value"] || {}
43
+ next [] unless value["messaging_product"] == "whatsapp"
44
+
45
+ metadata = value["metadata"] || {}
46
+ names = Array(value["contacts"]).to_h { |contact| [contact["wa_id"].to_s, contact] }
47
+ Array(value["messages"]).map { |raw| build_message(raw, metadata, names) }
48
+ end
49
+ end
50
+
51
+ def build_message(raw, metadata, names)
52
+ type = raw["type"].to_s
53
+ media = raw[type].is_a?(Hash) ? raw[type] : {}
54
+ InboundMessage.new(
55
+ provider: provider,
56
+ external_uid: raw["from"].to_s,
57
+ channel_id: metadata["phone_number_id"].to_s,
58
+ message_id: raw["id"].to_s,
59
+ kind: type,
60
+ text: (raw.dig("text", "body").to_s if type == "text"),
61
+ name: names.dig(raw["from"].to_s, "profile", "name"),
62
+ timestamp: raw["timestamp"].to_s,
63
+ media_id: media["id"],
64
+ media_type: media["mime_type"]
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ # Constant-time comparison without ActiveSupport.
71
+ def secure_compare(first, second)
72
+ return false unless first.bytesize == second.bytesize
73
+
74
+ left = first.unpack("C*")
75
+ right = second.unpack("C*")
76
+ right.each_with_index { |byte, index| left[index] ^= byte }
77
+ left.all?(&:zero?)
78
+ end
79
+ end
80
+
81
+ def initialize(phone_number_id:, access_token:, graph_version: nil, client: nil)
82
+ @client = client || Client.new(
83
+ phone_number_id: phone_number_id,
84
+ access_token: access_token,
85
+ graph_version: graph_version
86
+ )
87
+ end
88
+
89
+ def deliver(to:, text:)
90
+ @client.send_text(to: to, text: text)
91
+ end
92
+
93
+ def mark_read(message_id:)
94
+ @client.mark_read(message_id: message_id)
95
+ end
96
+
97
+ # Downloads an inbound attachment (image, voice note, document).
98
+ def media(media_id)
99
+ @client.fetch_media(media_id)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module ChannelProviders
5
+ module WhatsApp
6
+ # Sends text messages and read receipts through the Meta Graph API for
7
+ # one WhatsApp phone number. Long replies become several WhatsApp
8
+ # messages, split on paragraph/line boundaries so nothing is cut
9
+ # mid-word.
10
+ class Client
11
+ DEFAULT_GRAPH_VERSION = "v23.0"
12
+ BASE_URL = "https://graph.facebook.com"
13
+ MAX_TEXT_LENGTH = 4096
14
+
15
+ class << self
16
+ # The shared transport; swap in tests or for another HTTP stack.
17
+ attr_writer :transport
18
+
19
+ def transport
20
+ @transport ||= Transport.new
21
+ end
22
+ end
23
+
24
+ def initialize(phone_number_id:, access_token:, graph_version: nil, transport: nil)
25
+ @phone_number_id = phone_number_id
26
+ @access_token = access_token
27
+ @graph_version = graph_version.to_s.empty? ? DEFAULT_GRAPH_VERSION : graph_version
28
+ @transport = transport || self.class.transport
29
+ end
30
+
31
+ # Sends +text+ (splitting if needed).
32
+ #
33
+ # @return [String, nil] the last message id the provider returned
34
+ def send_text(to:, text:)
35
+ chunks(text).map do |chunk|
36
+ response = post("messages", {
37
+ messaging_product: "whatsapp",
38
+ to: to,
39
+ type: "text",
40
+ text: {body: chunk, preview_url: false}
41
+ })
42
+ response.dig("messages", 0, "id")
43
+ end.compact.last
44
+ end
45
+
46
+ def mark_read(message_id:)
47
+ post("messages", {
48
+ messaging_product: "whatsapp",
49
+ status: "read",
50
+ message_id: message_id
51
+ })
52
+ end
53
+
54
+ # Downloads an inbound attachment by its media handle: the Graph API
55
+ # answers a short-lived URL, which is fetched with the same bearer.
56
+ #
57
+ # @return [Hash] {body:, mime_type:} with the raw bytes
58
+ def fetch_media(media_id)
59
+ meta_response = @transport.get(
60
+ url: "#{BASE_URL}/#{@graph_version}/#{media_id}",
61
+ headers: {"Authorization" => "Bearer #{@access_token}"}
62
+ )
63
+ raise APIError, "graph api returned #{meta_response.status}: #{meta_response.body}" unless meta_response.success?
64
+
65
+ info = JSON.parse(meta_response.body)
66
+ file_response = @transport.get(url: info["url"], headers: {"Authorization" => "Bearer #{@access_token}"})
67
+ raise APIError, "media download returned #{file_response.status}" unless file_response.success?
68
+
69
+ {body: file_response.body, mime_type: info["mime_type"]}
70
+ rescue JSON::ParserError, SocketError, Timeout::Error, SystemCallError => e
71
+ raise APIError, e.message
72
+ end
73
+
74
+ # Splits a reply into WhatsApp-sized messages at the last paragraph,
75
+ # line, or word boundary before the limit.
76
+ def chunks(text)
77
+ remaining = text.to_s.strip
78
+ parts = []
79
+ while remaining.length > MAX_TEXT_LENGTH
80
+ boundary = remaining.rindex(/\n\n/, MAX_TEXT_LENGTH) ||
81
+ remaining.rindex("\n", MAX_TEXT_LENGTH) ||
82
+ remaining.rindex(" ", MAX_TEXT_LENGTH) ||
83
+ MAX_TEXT_LENGTH
84
+ parts << remaining[0...boundary].strip
85
+ remaining = remaining[boundary..].to_s.strip
86
+ end
87
+ parts << remaining unless remaining.empty?
88
+ parts
89
+ end
90
+
91
+ private
92
+
93
+ def post(path, body)
94
+ response = @transport.post(
95
+ url: "#{BASE_URL}/#{@graph_version}/#{@phone_number_id}/#{path}",
96
+ headers: {
97
+ "Authorization" => "Bearer #{@access_token}",
98
+ "Content-Type" => "application/json"
99
+ },
100
+ body: JSON.generate(body)
101
+ )
102
+ unless response.success?
103
+ raise APIError, "graph api returned #{response.status}: #{response.body}"
104
+ end
105
+
106
+ JSON.parse(response.body)
107
+ rescue JSON::ParserError, SocketError, Timeout::Error, SystemCallError => e
108
+ raise APIError, e.message
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Ask
8
+ module ChannelProviders
9
+ module WhatsApp
10
+ # The HTTP transport for Graph API calls. One small seam so tests (and
11
+ # alternate HTTP stacks) can swap the implementation.
12
+ class Transport
13
+ OPEN_TIMEOUT = 10
14
+ READ_TIMEOUT = 15
15
+
16
+ Response = Struct.new(:status, :body, keyword_init: true) do
17
+ def success?
18
+ (200..299).cover?(status)
19
+ end
20
+ end
21
+
22
+ def post(url:, headers:, body:)
23
+ uri = URI(url)
24
+ request = Net::HTTP::Post.new(uri)
25
+ headers.each { |key, value| request[key] = value }
26
+ request.body = body
27
+ perform(uri, request)
28
+ end
29
+
30
+ def get(url:, headers: {})
31
+ uri = URI(url)
32
+ request = Net::HTTP::Get.new(uri)
33
+ headers.each { |key, value| request[key] = value }
34
+ perform(uri, request)
35
+ end
36
+
37
+ private
38
+
39
+ def perform(uri, request)
40
+ response = Net::HTTP.start(
41
+ uri.host, uri.port,
42
+ use_ssl: uri.scheme == "https",
43
+ open_timeout: OPEN_TIMEOUT,
44
+ read_timeout: READ_TIMEOUT
45
+ ) { |http| http.request(request) }
46
+ Response.new(status: response.code.to_i, body: response.body.to_s)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "whatsapp/transport"
4
+ require_relative "whatsapp/client"
5
+ require_relative "whatsapp/adapter"
6
+
7
+ module Ask
8
+ module ChannelProviders
9
+ # WhatsApp Cloud API support (Meta's Graph API, direct — no BSP).
10
+ module WhatsApp
11
+ end
12
+ end
13
+ end
@@ -3,7 +3,10 @@
3
3
  require_relative "channel_providers/version"
4
4
  require_relative "channel_providers/card"
5
5
  require_relative "channel_providers/adapter"
6
+ require_relative "channel_providers/inbound_message"
7
+ require_relative "channel_providers/webhook_adapter"
6
8
  require_relative "channel_providers/telegram"
9
+ require_relative "channel_providers/whatsapp"
7
10
 
8
11
  module Ask
9
12
  module ChannelProviders
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-channel-providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -94,10 +94,16 @@ files:
94
94
  - lib/ask/channel_providers.rb
95
95
  - lib/ask/channel_providers/adapter.rb
96
96
  - lib/ask/channel_providers/card.rb
97
+ - lib/ask/channel_providers/inbound_message.rb
97
98
  - lib/ask/channel_providers/telegram.rb
98
99
  - lib/ask/channel_providers/telegram/adapter.rb
99
100
  - lib/ask/channel_providers/telegram/bot.rb
100
101
  - lib/ask/channel_providers/version.rb
102
+ - lib/ask/channel_providers/webhook_adapter.rb
103
+ - lib/ask/channel_providers/whatsapp.rb
104
+ - lib/ask/channel_providers/whatsapp/adapter.rb
105
+ - lib/ask/channel_providers/whatsapp/client.rb
106
+ - lib/ask/channel_providers/whatsapp/transport.rb
101
107
  homepage: https://github.com/ask-rb/ask-channel-providers
102
108
  licenses:
103
109
  - MIT