fopost 0.1.0 → 0.3.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.
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.whatsapp` — a WhatsApp Business connection.
6
+ #
7
+ # The platform owns templates, flows, the profile and the commerce settings,
8
+ # so every method here is a live read or write against the customer's own
9
+ # WhatsApp Business Account. All of it answers 503 until WhatsApp is set up.
10
+ class Whatsapp < Base
11
+ # ─── Profile ─────────────────────────────────────────────────
12
+
13
+ def profile(account_id)
14
+ WhatsappProfile.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/profile")))
15
+ end
16
+
17
+ def update_profile(account_id, about: UNSET, address: UNSET, description: UNSET,
18
+ vertical: UNSET, websites: UNSET, profile_picture_media_id: UNSET)
19
+ body = compact_unset(
20
+ 'about' => about,
21
+ 'address' => address,
22
+ 'description' => description,
23
+ 'vertical' => vertical,
24
+ 'websites' => websites,
25
+ 'profile_picture_media_id' => profile_picture_media_id
26
+ )
27
+ WhatsappProfile.new(unwrap(http.request(:patch, "/accounts/#{account_id}/whatsapp/profile", json: body)))
28
+ end
29
+
30
+ # A review, not a write: the number keeps its old name until it passes.
31
+ def request_display_name(account_id, display_name)
32
+ body = { 'display_name' => display_name }
33
+ as_hash(unwrap(http.post("/accounts/#{account_id}/whatsapp/profile/display-name", body)))
34
+ end
35
+
36
+ def set_username(account_id, username)
37
+ body = { 'username' => username }
38
+ WhatsappProfile.new(unwrap(http.put("/accounts/#{account_id}/whatsapp/profile/username", body)))
39
+ end
40
+
41
+ # ─── Templates ───────────────────────────────────────────────
42
+
43
+ def templates(account_id, after: nil)
44
+ params = compact_nil('after' => after)
45
+ parse_list(WhatsappTemplate, unwrap(http.get("/accounts/#{account_id}/whatsapp/templates", params)))
46
+ end
47
+
48
+ # The platform's pre-written templates, for adapting instead of drafting.
49
+ def template_library(account_id, search: nil)
50
+ params = compact_nil('search' => search)
51
+ body = unwrap(http.get("/accounts/#{account_id}/whatsapp/templates/library", params))
52
+ body.is_a?(Array) ? body : []
53
+ end
54
+
55
+ def template(account_id, template_id)
56
+ WhatsappTemplate.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/templates/#{template_id}")))
57
+ end
58
+
59
+ # Files a template for review; the result carries the status it was given.
60
+ def create_template(account_id, name:, language:, category:, components:,
61
+ allow_category_change: nil)
62
+ body = compact_nil(
63
+ 'name' => name,
64
+ 'language' => language,
65
+ 'category' => category,
66
+ 'components' => components.map { |c| c.transform_keys(&:to_s) },
67
+ 'allow_category_change' => allow_category_change
68
+ )
69
+ WhatsappTemplate.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/templates", body)))
70
+ end
71
+
72
+ def import_template(account_id, library_template_name:, name:, language:, category:,
73
+ library_template_button_inputs: nil)
74
+ body = compact_nil(
75
+ 'library_template_name' => library_template_name,
76
+ 'name' => name,
77
+ 'language' => language,
78
+ 'category' => category,
79
+ 'library_template_button_inputs' => library_template_button_inputs
80
+ )
81
+ WhatsappTemplate.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/templates/import", body)))
82
+ end
83
+
84
+ def update_template(account_id, template_id, category: nil, components: nil)
85
+ body = compact_nil('category' => category, 'components' => components)
86
+ path = "/accounts/#{account_id}/whatsapp/templates/#{template_id}"
87
+ WhatsappTemplate.new(unwrap(http.request(:patch, path, json: body)))
88
+ end
89
+
90
+ # The name is required: it is what the platform deletes by.
91
+ def delete_template(account_id, template_id, name:)
92
+ query = URI.encode_www_form('name' => name)
93
+ as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/templates/#{template_id}?#{query}")))
94
+ end
95
+
96
+ # ─── Groups ──────────────────────────────────────────────────
97
+
98
+ def groups(account_id)
99
+ parse_list(WhatsappGroup, unwrap(http.get("/accounts/#{account_id}/whatsapp/groups")))
100
+ end
101
+
102
+ # Participation is invite-only: send the invite link, there is no add.
103
+ def create_group(account_id, subject:, description: nil)
104
+ body = compact_nil('subject' => subject, 'description' => description)
105
+ WhatsappGroup.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/groups", body)))
106
+ end
107
+
108
+ def group(account_id, group_id)
109
+ WhatsappGroup.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/groups/#{group_id}")))
110
+ end
111
+
112
+ def update_group(account_id, group_id, subject: nil, description: nil)
113
+ body = compact_nil('subject' => subject, 'description' => description)
114
+ path = "/accounts/#{account_id}/whatsapp/groups/#{group_id}"
115
+ WhatsappGroup.new(unwrap(http.request(:patch, path, json: body)))
116
+ end
117
+
118
+ def delete_group(account_id, group_id)
119
+ as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/groups/#{group_id}")))
120
+ end
121
+
122
+ def group_invite_link(account_id, group_id)
123
+ body = as_hash(unwrap(http.get("/accounts/#{account_id}/whatsapp/groups/#{group_id}/invite-link")))
124
+ body['inviteLink']
125
+ end
126
+
127
+ # Issues a new link and invalidates the old one.
128
+ def reset_group_invite_link(account_id, group_id)
129
+ body = as_hash(unwrap(http.post("/accounts/#{account_id}/whatsapp/groups/#{group_id}/invite-link")))
130
+ body['inviteLink']
131
+ end
132
+
133
+ def remove_group_participants(account_id, group_id, users)
134
+ path = "/accounts/#{account_id}/whatsapp/groups/#{group_id}/participants"
135
+ as_hash(unwrap(http.delete(path, { 'users' => users.to_a })))
136
+ end
137
+
138
+ # ─── Blocking ────────────────────────────────────────────────
139
+
140
+ def blocked(account_id, after: nil)
141
+ params = compact_nil('after' => after)
142
+ body = unwrap(http.get("/accounts/#{account_id}/whatsapp/block", params))
143
+ body.is_a?(Array) ? body : []
144
+ end
145
+
146
+ def block_users(account_id, users)
147
+ body = { 'users' => users.to_a }
148
+ WhatsappBlockResult.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/block", body)))
149
+ end
150
+
151
+ def unblock_users(account_id, users)
152
+ body = { 'users' => users.to_a }
153
+ WhatsappBlockResult.new(unwrap(http.delete("/accounts/#{account_id}/whatsapp/block", body)))
154
+ end
155
+
156
+ # ─── Commerce ────────────────────────────────────────────────
157
+
158
+ def commerce_settings(account_id)
159
+ WhatsappCommerceSettings.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/commerce")))
160
+ end
161
+
162
+ def update_commerce_settings(account_id, cart_enabled: nil, catalog_visible: nil)
163
+ body = compact_nil('is_cart_enabled' => cart_enabled, 'is_catalog_visible' => catalog_visible)
164
+ path = "/accounts/#{account_id}/whatsapp/commerce"
165
+ WhatsappCommerceSettings.new(unwrap(http.request(:patch, path, json: body)))
166
+ end
167
+
168
+ def link_catalog(account_id, catalog_id)
169
+ body = { 'catalog_id' => catalog_id }
170
+ path = "/accounts/#{account_id}/whatsapp/commerce/catalog"
171
+ WhatsappCommerceSettings.new(unwrap(http.post(path, body)))
172
+ end
173
+
174
+ # ─── Flows ───────────────────────────────────────────────────
175
+
176
+ def flows(account_id)
177
+ parse_list(WhatsappFlow, unwrap(http.get("/accounts/#{account_id}/whatsapp/flows")))
178
+ end
179
+
180
+ def flow(account_id, flow_id)
181
+ WhatsappFlow.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/flows/#{flow_id}")))
182
+ end
183
+
184
+ def create_flow(account_id, name:, categories:, endpoint_uri: nil, clone_flow_id: nil)
185
+ body = compact_nil(
186
+ 'name' => name,
187
+ 'categories' => categories.to_a,
188
+ 'endpoint_uri' => endpoint_uri,
189
+ 'clone_flow_id' => clone_flow_id
190
+ )
191
+ WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows", body)))
192
+ end
193
+
194
+ def update_flow(account_id, flow_id, name: nil, categories: nil, endpoint_uri: nil)
195
+ body = compact_nil('name' => name, 'categories' => categories, 'endpoint_uri' => endpoint_uri)
196
+ path = "/accounts/#{account_id}/whatsapp/flows/#{flow_id}"
197
+ WhatsappFlow.new(unwrap(http.request(:patch, path, json: body)))
198
+ end
199
+
200
+ # Drafts only; a published flow is deprecated instead.
201
+ def delete_flow(account_id, flow_id)
202
+ as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/flows/#{flow_id}")))
203
+ end
204
+
205
+ # The platform answers with its validation errors rather than refusing.
206
+ def upload_flow_json(account_id, flow_id, flow_json)
207
+ body = { 'flow_json' => flow_json }
208
+ path = "/accounts/#{account_id}/whatsapp/flows/#{flow_id}/json"
209
+ WhatsappFlowJsonResult.new(unwrap(http.put(path, body)))
210
+ end
211
+
212
+ def publish_flow(account_id, flow_id)
213
+ WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows/#{flow_id}/publish")))
214
+ end
215
+
216
+ def deprecate_flow(account_id, flow_id)
217
+ WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows/#{flow_id}/deprecate")))
218
+ end
219
+
220
+ def flow_responses(account_id)
221
+ path = "/accounts/#{account_id}/whatsapp/flows/responses"
222
+ parse_list(WhatsappFlowResponse, unwrap(http.get(path)))
223
+ end
224
+
225
+ def encryption_key_status(account_id)
226
+ path = "/accounts/#{account_id}/whatsapp/flows/encryption-key"
227
+ WhatsappEncryptionKeyStatus.new(unwrap(http.get(path)))
228
+ end
229
+
230
+ # The public half only; the private half stays with the customer.
231
+ def set_encryption_key(account_id, business_public_key)
232
+ body = { 'business_public_key' => business_public_key }
233
+ path = "/accounts/#{account_id}/whatsapp/flows/encryption-key"
234
+ WhatsappEncryptionKeyStatus.new(unwrap(http.put(path, body)))
235
+ end
236
+
237
+ # ─── Account state and sandbox ───────────────────────────────
238
+
239
+ def account_events(account_id)
240
+ as_hash(unwrap(http.get("/accounts/#{account_id}/whatsapp/events")))
241
+ end
242
+
243
+ def sandbox_sessions(workspace_id:)
244
+ params = { 'workspaceId' => workspace_id }
245
+ parse_list(WhatsappSandboxSession, unwrap(http.get('/whatsapp/sandbox/sessions', params)))
246
+ end
247
+
248
+ # Sends a template from the platform-owned test number; needs the publish scope.
249
+ def create_sandbox_session(workspace_id:, phone_number:)
250
+ body = { 'workspaceId' => workspace_id, 'phoneNumber' => phone_number }
251
+ WhatsappSandboxSession.new(unwrap(http.post('/whatsapp/sandbox/sessions', body)))
252
+ end
253
+ end
254
+ end
255
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fopost
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'time'
6
+
7
+ require 'fopost/models'
8
+
9
+ module Fopost
10
+ # Raised when a webhook delivery cannot be trusted.
11
+ class WebhookSignatureError < StandardError; end
12
+
13
+ # Verifies the signature FoPost puts on every outbound webhook, including
14
+ # the website target of the blog.
15
+ #
16
+ # event = Fopost::Webhooks.verify(request.raw_post, request.headers, secret)
17
+ # event.article.title if event.event == 'article.published'
18
+ #
19
+ # Verify the raw request bytes, never re-serialised JSON: key order and
20
+ # whitespace change the bytes and the signature no longer matches.
21
+ module Webhooks
22
+ SIGNATURE_HEADER = 'x-fopost-signature'
23
+ TIMESTAMPED_SIGNATURE_HEADER = 'x-fopost-signature-256'
24
+ TIMESTAMP_HEADER = 'x-fopost-timestamp'
25
+ EVENT_HEADER = 'x-fopost-event'
26
+ DELIVERY_HEADER = 'x-fopost-delivery'
27
+ DEFAULT_TOLERANCE = 300
28
+
29
+ module_function
30
+
31
+ # Returns the parsed envelope as a {Fopost::WebhookEvent}, or raises
32
+ # {Fopost::WebhookSignatureError}.
33
+ #
34
+ # The timestamped signature is checked when `X-FoPost-Timestamp` is
35
+ # present and rejected when it is older than `tolerance` seconds; the
36
+ # plain body signature is accepted only when no timestamp was sent.
37
+ # `headers` may use any casing; a Rack `HTTP_X_FOPOST_*` key also works.
38
+ def verify(raw_body, headers, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
39
+ raise WebhookSignatureError, 'fopost: a webhook secret is required' if secret.nil? || secret.to_s.empty?
40
+
41
+ body = raw_body.to_s
42
+ lookup = normalize_headers(headers)
43
+ timestamp = lookup[TIMESTAMP_HEADER]
44
+
45
+ if timestamp
46
+ check_timestamp(timestamp, tolerance, now)
47
+ expected = sign("#{timestamp}.#{body}", secret)
48
+ match = secure_equal(lookup[TIMESTAMPED_SIGNATURE_HEADER], expected)
49
+ else
50
+ match = secure_equal(lookup[SIGNATURE_HEADER], sign(body, secret))
51
+ end
52
+ raise WebhookSignatureError, 'fopost: webhook signature mismatch' unless match
53
+
54
+ parse(body)
55
+ end
56
+
57
+ # The header value FoPost sends for these exact bytes.
58
+ def sign(payload, secret)
59
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, payload)}"
60
+ end
61
+
62
+ def check_timestamp(timestamp, tolerance, now)
63
+ seconds = Integer(timestamp.to_s.strip, exception: false)
64
+ raise WebhookSignatureError, 'fopost: webhook timestamp is not a number' unless seconds
65
+
66
+ drift = ((now || Time.now).to_i - seconds).abs
67
+ raise WebhookSignatureError, 'fopost: webhook timestamp outside tolerance' if drift > tolerance
68
+ end
69
+
70
+ def secure_equal(given, expected)
71
+ return false if given.nil?
72
+
73
+ given = given.to_s.strip
74
+ return false unless given.bytesize == expected.bytesize
75
+
76
+ OpenSSL.fixed_length_secure_compare(given, expected)
77
+ end
78
+
79
+ def parse(body)
80
+ data = JSON.parse(body)
81
+ raise WebhookSignatureError, 'fopost: webhook body is not an object' unless data.is_a?(Hash)
82
+
83
+ WebhookEvent.new(data)
84
+ rescue JSON::ParserError
85
+ raise WebhookSignatureError, 'fopost: webhook body is not JSON'
86
+ end
87
+
88
+ def normalize_headers(headers)
89
+ (headers || {}).each_with_object({}) do |(key, value), out|
90
+ name = key.to_s.downcase.tr('_', '-').sub(/\Ahttp-/, '')
91
+ out[name] = value.is_a?(Array) ? value.first : value
92
+ end
93
+ end
94
+
95
+ private_class_method :check_timestamp, :secure_equal, :parse, :normalize_headers
96
+ end
97
+ end
data/lib/fopost.rb CHANGED
@@ -6,11 +6,13 @@ require 'fopost/unset'
6
6
  require 'fopost/platforms'
7
7
  require 'fopost/model'
8
8
  require 'fopost/models'
9
+ require 'fopost/file_part'
9
10
  require 'fopost/http/response'
10
11
  require 'fopost/http/transport'
11
12
  require 'fopost/http/net_http_transport'
12
13
  require 'fopost/http/client'
13
14
  require 'fopost/client'
15
+ require 'fopost/webhooks'
14
16
 
15
17
  # Official Ruby SDK for the FoPost API.
16
18
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fopost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - FoPost
@@ -22,21 +22,39 @@ files:
22
22
  - lib/fopost.rb
23
23
  - lib/fopost/client.rb
24
24
  - lib/fopost/errors.rb
25
+ - lib/fopost/file_part.rb
25
26
  - lib/fopost/http/client.rb
27
+ - lib/fopost/http/multipart.rb
26
28
  - lib/fopost/http/net_http_transport.rb
27
29
  - lib/fopost/http/response.rb
28
30
  - lib/fopost/http/transport.rb
29
31
  - lib/fopost/model.rb
30
32
  - lib/fopost/models.rb
31
33
  - lib/fopost/platforms.rb
34
+ - lib/fopost/resources/account_groups.rb
32
35
  - lib/fopost/resources/accounts.rb
36
+ - lib/fopost/resources/activity.rb
37
+ - lib/fopost/resources/ads.rb
33
38
  - lib/fopost/resources/ai.rb
39
+ - lib/fopost/resources/analytics.rb
34
40
  - lib/fopost/resources/base.rb
41
+ - lib/fopost/resources/blog.rb
42
+ - lib/fopost/resources/blogs.rb
43
+ - lib/fopost/resources/broadcasts.rb
44
+ - lib/fopost/resources/contacts.rb
45
+ - lib/fopost/resources/google_ads.rb
46
+ - lib/fopost/resources/google_business.rb
47
+ - lib/fopost/resources/inbox.rb
48
+ - lib/fopost/resources/knowledge.rb
35
49
  - lib/fopost/resources/labels.rb
50
+ - lib/fopost/resources/media.rb
36
51
  - lib/fopost/resources/posts.rb
52
+ - lib/fopost/resources/validate.rb
53
+ - lib/fopost/resources/whatsapp.rb
37
54
  - lib/fopost/resources/workspaces.rb
38
55
  - lib/fopost/unset.rb
39
56
  - lib/fopost/version.rb
57
+ - lib/fopost/webhooks.rb
40
58
  homepage: https://fopost.com
41
59
  licenses:
42
60
  - MIT