fopost 0.2.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.
@@ -2,12 +2,12 @@
2
2
 
3
3
  module Fopost
4
4
  module Resources
5
- # `client.inbox` — comments, mentions and direct messages on connected
5
+ # `client.inbox` — comments, mentions, reviews and direct messages on connected
6
6
  # accounts. Every method needs the `inbox` scope.
7
7
  class Inbox < Base
8
8
  # One page of items, newest first. The result is Enumerable over its items.
9
9
  #
10
- # `type` is comment, mention or dm; `state` is unread, read, resolved or
10
+ # `type` is comment, mention, review or dm; `state` is unread, read, resolved or
11
11
  # snoozed; `sort` is newest, oldest or unanswered.
12
12
  def list(workspace_id: nil, type: nil, state: nil, platform: nil, account_id: nil, post_id: nil,
13
13
  post_external_id: nil, conversation_id: nil, direction: nil, q: nil, sort: nil,
@@ -34,7 +34,8 @@ module Fopost
34
34
  end
35
35
 
36
36
  # One row per post with comments; `kind: 'mentions'` for posts the
37
- # account was tagged in.
37
+ # account was tagged in, `kind: 'reviews'` for one row per review left
38
+ # on the business, each carrying its rating.
38
39
  def threads(workspace_id: nil, kind: nil, platform: nil, account_id: nil, state: nil, q: nil,
39
40
  sort: nil, page: 1, per_page: 25)
40
41
  body = http.get(
@@ -113,9 +114,16 @@ module Fopost
113
114
  InboxItem.new(unwrap(http.request(:patch, "/inbox/#{item_id}", json: body)))
114
115
  end
115
116
 
116
- # Send the reply on the platform as the connected account.
117
- def reply(item_id, text:)
118
- InboxReplyResult.new(unwrap(http.post("/inbox/#{item_id}/reply", { 'text' => text })))
117
+ # Edit a comment the account wrote, on the platform. Needs `publish`.
118
+ def edit_comment(item_id, text:)
119
+ InboxItem.new(unwrap(http.request(:patch, "/inbox/#{item_id}", json: { 'text' => text })))
120
+ end
121
+
122
+ # Send the reply on the platform as the connected account. `text` may be
123
+ # left out when `media_ids` is given; `media_ids` and `quick_replies` need `publish`.
124
+ def reply(item_id, text: nil, media_ids: nil, quick_replies: nil)
125
+ body = compact_nil('text' => text, 'media_ids' => media_ids, 'quick_replies' => quick_replies)
126
+ InboxReplyResult.new(unwrap(http.post("/inbox/#{item_id}/reply", body)))
119
127
  end
120
128
 
121
129
  def hide(item_id)
@@ -126,12 +134,70 @@ module Fopost
126
134
  InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unhide")))
127
135
  end
128
136
 
129
- # Delete the comment on the platform.
137
+ # Delete the comment on the platform, or our own reply (that also needs `publish`).
130
138
  def delete(item_id)
131
139
  http.delete("/inbox/#{item_id}")
132
140
  nil
133
141
  end
134
142
 
143
+ # Like the item (an upvote on Reddit, a favourite on Mastodon). Needs `publish`.
144
+ def like(item_id)
145
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/like")))
146
+ end
147
+
148
+ def unlike(item_id)
149
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unlike")))
150
+ end
151
+
152
+ # Vote up or down where the network ranks by votes (Reddit), where `can_vote`
153
+ # is true. `direction` is 'up', 'down', or 'none' to take an earlier vote
154
+ # back. An upvote is the same call a like makes, so `liked` moves with it.
155
+ # Needs `publish`.
156
+ def vote(item_id, direction)
157
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/vote", { 'direction' => direction })))
158
+ end
159
+
160
+ # Pin our own comment. Needs `publish`.
161
+ def pin(item_id)
162
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/pin")))
163
+ end
164
+
165
+ def unpin(item_id)
166
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unpin")))
167
+ end
168
+
169
+ # React to a DM with an emoji; `nil` removes ours. Needs `publish`.
170
+ def react(item_id, reaction:)
171
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/react", { 'reaction' => reaction })))
172
+ end
173
+
174
+ # Open a DM: by `handle` from `account_id`, or as a private reply to the
175
+ # inbox comment `comment_id`. Needs `publish`.
176
+ def start_conversation(text:, account_id: nil, handle: nil, comment_id: nil, media_ids: nil)
177
+ body = compact_nil(
178
+ 'account_id' => account_id,
179
+ 'handle' => handle,
180
+ 'comment_id' => comment_id,
181
+ 'text' => text,
182
+ 'media_ids' => media_ids
183
+ )
184
+ InboxStartedConversation.new(unwrap(http.post('/inbox/conversations', body)))
185
+ end
186
+
187
+ # Show or clear the typing indicator in a DM thread. Returns whether it is on. Needs `publish`.
188
+ def set_typing(conversation_id, account_id:, on: true)
189
+ body = { 'account_id' => account_id, 'on' => on }
190
+ as_hash(unwrap(http.post("/inbox/conversations/#{conversation_id}/typing", body)))['typing']
191
+ end
192
+
193
+ # Pass a Messenger thread to another Meta app, or take it back without an `app_id`.
194
+ def handover(conversation_id, account_id:, app_id: nil, metadata: nil)
195
+ body = { 'account_id' => account_id }
196
+ body['app_id'] = app_id unless app_id.nil?
197
+ body['metadata'] = metadata unless metadata.nil?
198
+ InboxHandover.new(unwrap(http.post("/inbox/conversations/#{conversation_id}/handover", body)))
199
+ end
200
+
135
201
  # Replies an automation or the agent drafted that a person still has to send.
136
202
  def approvals(workspace_id: nil)
137
203
  parse_list(InboxApproval, unwrap(http.get('/inbox/approvals', { 'workspace_id' => workspace_id })))
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.knowledge` — what the workspace has told FoPost about itself.
6
+ #
7
+ # A source is an FAQ, a note, a page on your own site, or a plain-text/CSV
8
+ # item from the media library. Retrieval over these is what grounds a
9
+ # drafted inbox reply in your own answers instead of an invented one.
10
+ # Needs the `inbox` scope.
11
+ class Knowledge < Base
12
+ def list(workspace_id: nil)
13
+ parse_list(
14
+ KnowledgeSource,
15
+ unwrap(http.get('/knowledge/sources', { 'workspace_id' => workspace_id }))
16
+ )
17
+ end
18
+
19
+ # Adds a source and queues it for indexing, so it comes back `pending`.
20
+ #
21
+ # `kind` is `faq`, `text`, `url` or `file`. An `faq` or `text` source
22
+ # needs `content`, a `url` source needs `url`, and a `file` source needs
23
+ # `media_id` pointing at a plain-text or CSV item in the same workspace.
24
+ def create(kind:, title:, content: nil, url: nil, media_id: nil, brand_voice_id: nil,
25
+ workspace_id: nil)
26
+ body = compact_nil(
27
+ 'kind' => kind,
28
+ 'title' => title,
29
+ 'content' => content,
30
+ 'url' => url,
31
+ 'media_id' => media_id,
32
+ 'brand_voice_id' => brand_voice_id,
33
+ 'workspace_id' => workspace_id
34
+ )
35
+ KnowledgeSource.new(unwrap(http.post('/knowledge/sources', body)))
36
+ end
37
+
38
+ # Partial update. Changing the content or the URL returns the source to
39
+ # `pending` and re-indexes it.
40
+ def update(source_id, title: UNSET, content: UNSET, url: UNSET, brand_voice_id: UNSET)
41
+ body = compact_unset(
42
+ 'title' => title,
43
+ 'content' => content,
44
+ 'url' => url,
45
+ 'brand_voice_id' => brand_voice_id
46
+ )
47
+ KnowledgeSource.new(unwrap(http.patch("/knowledge/sources/#{source_id}", body)))
48
+ end
49
+
50
+ # Removes the source and every passage indexed from it.
51
+ def delete(source_id)
52
+ http.delete("/knowledge/sources/#{source_id}")
53
+ nil
54
+ end
55
+
56
+ # Reads the source again — a `url` source is re-fetched. Returns once queued.
57
+ def sync(source_id)
58
+ http.post("/knowledge/sources/#{source_id}/sync", {})
59
+ nil
60
+ end
61
+
62
+ # The passages closest to a question, best first. Empty is the honest
63
+ # answer when nothing stored answers it.
64
+ def search(q, top_k: nil, brand_voice_id: nil, workspace_id: nil)
65
+ parse_list(KnowledgeMatch, unwrap(http.get('/knowledge/search', {
66
+ 'q' => q,
67
+ 'top_k' => top_k,
68
+ 'brand_voice_id' => brand_voice_id,
69
+ 'workspace_id' => workspace_id
70
+ })))
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.media` — direct uploads to the media library.
6
+ #
7
+ # A direct upload is three steps: {#presign} reserves a slot, the bytes go
8
+ # straight to `upload_url` with the returned headers, and {#complete}
9
+ # turns the slot into a library item. {#upload_direct} does all three.
10
+ class Media < Base
11
+ # What a multipart request carries per file when `/media/limits` cannot be read.
12
+ DEFAULT_MULTIPART_MAX_FILE_BYTES = 100 * 1024 * 1024
13
+
14
+ # The upload ceilings for the account, by connected accounts or add-on.
15
+ def limits(workspace_id: nil)
16
+ UploadLimits.new(unwrap(http.get('/media/limits', { 'workspaceId' => workspace_id })))
17
+ end
18
+
19
+ # Reserve an upload slot. `size` is the exact byte count of the file, up
20
+ # to the account's `max_file_bytes`.
21
+ def presign(workspace_id:, filename:, mime_type:, size:, folder_id: nil, origin: nil, brand_role: nil,
22
+ brand_voice_id: nil, person_consent: nil)
23
+ body = {
24
+ 'workspaceId' => workspace_id,
25
+ 'filename' => filename,
26
+ 'mimeType' => mime_type,
27
+ 'size' => size
28
+ }
29
+ body.merge!(
30
+ compact_nil(
31
+ 'folderId' => folder_id,
32
+ 'origin' => origin,
33
+ 'brandRole' => brand_role,
34
+ 'brandVoiceId' => brand_voice_id,
35
+ 'personConsent' => person_consent
36
+ )
37
+ )
38
+ PresignedUpload.new(unwrap(http.post('/media/presign', body)))
39
+ end
40
+
41
+ # Turn an uploaded slot into a media item.
42
+ def complete(upload_id)
43
+ MediaItem.new(unwrap(http.post("/media/presign/#{upload_id}/complete")))
44
+ end
45
+
46
+ # Presign, PUT `data` (a binary String), and complete in one call.
47
+ def upload_direct(workspace_id:, filename:, mime_type:, data:, **presign_options)
48
+ upload = presign(workspace_id: workspace_id, filename: filename, mime_type: mime_type,
49
+ size: data.bytesize, **presign_options)
50
+ headers = upload.headers.merge('Content-Length' => data.bytesize.to_s)
51
+ http.put_raw(upload.upload_url, data, headers)
52
+ complete(upload.upload_id)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Fopost
4
6
  module Resources
5
7
  # `client.posts` — create, schedule, publish, and inspect posts.
@@ -66,9 +68,14 @@ module Fopost
66
68
  # Create a draft or a scheduled post.
67
69
  #
68
70
  # `status` is "draft" or "scheduled"; a scheduled post needs `schedule_at`.
69
- # To send a post out now, create it and call {#publish}.
70
- def create(workspace_id:, content:, accounts: [], status: 'draft', schedule_at: nil,
71
- labels: nil, title: nil, summary: nil, content_type: nil, settings: nil, **extra)
71
+ # To send a post out now, create it and call {#publish}. `account_group_id`
72
+ # adds that group's accounts to `accounts`, so `accounts` may be left empty.
73
+ #
74
+ # `files` (paths, IOs, or `{ filename:, io:/data:, mime_type: }` hashes) are
75
+ # stored in the library and attached to the first block. Files over the
76
+ # multipart ceiling go through direct upload; the result is the same.
77
+ def create(workspace_id:, content:, accounts: [], account_group_id: nil, status: 'draft', schedule_at: nil,
78
+ labels: nil, title: nil, summary: nil, content_type: nil, settings: nil, files: nil, **extra)
72
79
  body = {
73
80
  'workspace_id' => workspace_id,
74
81
  'status' => status,
@@ -77,6 +84,7 @@ module Fopost
77
84
  }
78
85
  body.merge!(
79
86
  compact_nil(
87
+ 'account_group_id' => account_group_id,
80
88
  'schedule_at' => iso8601(schedule_at),
81
89
  'labels' => labels&.to_a,
82
90
  'title' => title,
@@ -87,6 +95,15 @@ module Fopost
87
95
  )
88
96
  body.merge!(stringify(extra))
89
97
 
98
+ parts = file_parts(files)
99
+ return Post.new(unwrap(http.post('/posts', body))) if parts.empty?
100
+
101
+ if multipart_fits?(parts, workspace_id)
102
+ fields = { 'post' => JSON.generate(body) }
103
+ return Post.new(unwrap(http.post_multipart('/posts', fields: fields, parts: multipart_parts(parts))))
104
+ end
105
+
106
+ attach_media(body['content'], upload_parts(workspace_id, parts))
90
107
  Post.new(unwrap(http.post('/posts', body)))
91
108
  end
92
109
 
@@ -94,7 +111,7 @@ module Fopost
94
111
  # to clear a field.
95
112
  def update(post_id, content: UNSET, accounts: UNSET, status: UNSET, schedule_at: UNSET,
96
113
  labels: UNSET, title: UNSET, summary: UNSET, content_type: UNSET,
97
- settings: UNSET, **extra)
114
+ settings: UNSET, files: nil, **extra)
98
115
  body = compact_unset(
99
116
  'content' => UNSET.equal?(content) ? UNSET : normalize_content(content),
100
117
  'accounts' => UNSET.equal?(accounts) ? UNSET : normalize_accounts(accounts),
@@ -108,6 +125,19 @@ module Fopost
108
125
  )
109
126
  body.merge!(stringify(extra))
110
127
 
128
+ parts = file_parts(files)
129
+ return Post.new(unwrap(http.put("/posts/#{post_id}", body))) if parts.empty?
130
+
131
+ if multipart_fits?(parts, nil)
132
+ fields = { 'post' => JSON.generate(body) }
133
+ multipart = http.put_multipart("/posts/#{post_id}", fields: fields, parts: multipart_parts(parts))
134
+ return Post.new(unwrap(multipart))
135
+ end
136
+
137
+ # The direct path needs the workspace, and the current blocks when none were given.
138
+ existing = get(post_id)
139
+ body['content'] = normalize_content(existing.content) unless body.key?('content')
140
+ attach_media(body['content'], upload_parts(existing.workspace_id, parts))
111
141
  Post.new(unwrap(http.put("/posts/#{post_id}", body)))
112
142
  end
113
143
 
@@ -180,6 +210,47 @@ module Fopost
180
210
  def stringify(hash)
181
211
  hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
182
212
  end
213
+
214
+ def file_parts(files)
215
+ Array(files).map { |file| FilePart.from(file) }
216
+ end
217
+
218
+ # One multipart request when every file fits its per-file ceiling.
219
+ def multipart_fits?(parts, workspace_id)
220
+ ceiling = begin
221
+ media.limits(workspace_id: workspace_id).multipart_max_file_bytes
222
+ rescue Error
223
+ nil
224
+ end
225
+ ceiling = Media::DEFAULT_MULTIPART_MAX_FILE_BYTES unless ceiling.is_a?(Integer)
226
+ parts.all? { |part| part.size <= ceiling }
227
+ end
228
+
229
+ def multipart_parts(parts)
230
+ parts.map do |part|
231
+ HTTP::Multipart::Part.new(name: 'files', filename: part.filename, mime_type: part.mime_type,
232
+ data: part.data)
233
+ end
234
+ end
235
+
236
+ def upload_parts(workspace_id, parts)
237
+ parts.map do |part|
238
+ item = media.upload_direct(workspace_id: workspace_id, filename: part.filename,
239
+ mime_type: part.mime_type, data: part.data)
240
+ compact_nil('type' => part.media_type, 'name' => item.name || part.filename, 'url' => item.url,
241
+ 'size' => item.size || part.size)
242
+ end
243
+ end
244
+
245
+ def attach_media(blocks, items)
246
+ blocks << {} if blocks.empty?
247
+ first = blocks.first
248
+ first['media'] = Array(first['media']) + items
249
+ end
250
+
251
+ def media
252
+ @media ||= Media.new(http)
253
+ end
183
254
  end
184
255
  end
185
256
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.validate` — check a draft, its length, or a media URL against
6
+ # platform rules before creating a post. Nothing is stored server-side.
7
+ class Validate < Base
8
+ # Full preflight: blockers per platform plus advisory signals.
9
+ def post(platforms:, content: nil, media: nil)
10
+ body = compact_nil(
11
+ 'content' => content,
12
+ 'media' => media&.map { |item| item.transform_keys(&:to_s) },
13
+ 'platforms' => platforms.to_a
14
+ )
15
+ ValidatePostResult.new(unwrap(http.post('/validate/post', body)))
16
+ end
17
+
18
+ # Text length against each platform's limit.
19
+ def length(text:, platforms:)
20
+ body = { 'text' => text, 'platforms' => platforms.to_a }
21
+ ValidateLengthResult.new(unwrap(http.post('/validate/length', body)))
22
+ end
23
+
24
+ # Fetch a public URL and check the file. Answers 200 even when a check fails.
25
+ def media(url:)
26
+ ValidateMediaResult.new(unwrap(http.post('/validate/media', { 'url' => url })))
27
+ end
28
+
29
+ # Whether a subreddit exists and takes a post from this Reddit account. The
30
+ # check runs with the account's own token, so `account_id` is required.
31
+ def subreddit(account_id:, name:)
32
+ params = { 'account_id' => account_id, 'name' => name }
33
+ SubredditCheck.new(unwrap(http.get('/validate/subreddit', params)))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -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.2.0'
4
+ VERSION = '0.3.0'
5
5
  end