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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +39 -2
- data/lib/ask/channel_providers/inbound_message.rb +51 -0
- data/lib/ask/channel_providers/version.rb +1 -1
- data/lib/ask/channel_providers/webhook_adapter.rb +82 -0
- data/lib/ask/channel_providers/whatsapp/adapter.rb +104 -0
- data/lib/ask/channel_providers/whatsapp/client.rb +113 -0
- data/lib/ask/channel_providers/whatsapp/transport.rb +51 -0
- data/lib/ask/channel_providers/whatsapp.rb +13 -0
- data/lib/ask/channel_providers.rb +3 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4aee83f6a3689cd90be057aa4c2fbe60be6ed9b2d9d03b982f068dd078da478e
|
|
4
|
+
data.tar.gz: 92375f391036782135a573601491cb94058292707598e2c6cea1a0c26838822d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
8
|
-
implemented
|
|
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
|
|
@@ -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.
|
|
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
|