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,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.inbox` — comments, mentions, reviews and direct messages on connected
6
+ # accounts. Every method needs the `inbox` scope.
7
+ class Inbox < Base
8
+ # One page of items, newest first. The result is Enumerable over its items.
9
+ #
10
+ # `type` is comment, mention, review or dm; `state` is unread, read, resolved or
11
+ # snoozed; `sort` is newest, oldest or unanswered.
12
+ def list(workspace_id: nil, type: nil, state: nil, platform: nil, account_id: nil, post_id: nil,
13
+ post_external_id: nil, conversation_id: nil, direction: nil, q: nil, sort: nil,
14
+ page: 1, per_page: 25)
15
+ body = http.get(
16
+ '/inbox',
17
+ {
18
+ 'workspace_id' => workspace_id,
19
+ 'type' => type,
20
+ 'state' => state,
21
+ 'platform' => platform,
22
+ 'account_id' => account_id,
23
+ 'post_id' => post_id,
24
+ 'post_external_id' => post_external_id,
25
+ 'conversation_id' => conversation_id,
26
+ 'direction' => direction,
27
+ 'q' => q,
28
+ 'sort' => sort,
29
+ 'page' => page,
30
+ 'per_page' => per_page
31
+ }
32
+ )
33
+ page_of(InboxItem, body)
34
+ end
35
+
36
+ # One row per post with comments; `kind: 'mentions'` for posts the
37
+ # account was tagged in, `kind: 'reviews'` for one row per review left
38
+ # on the business, each carrying its rating.
39
+ def threads(workspace_id: nil, kind: nil, platform: nil, account_id: nil, state: nil, q: nil,
40
+ sort: nil, page: 1, per_page: 25)
41
+ body = http.get(
42
+ '/inbox/posts',
43
+ {
44
+ 'workspace_id' => workspace_id,
45
+ 'kind' => kind,
46
+ 'platform' => platform,
47
+ 'account_id' => account_id,
48
+ 'state' => state,
49
+ 'q' => q,
50
+ 'sort' => sort,
51
+ 'page' => page,
52
+ 'per_page' => per_page
53
+ }
54
+ )
55
+ page_of(InboxThread, body)
56
+ end
57
+
58
+ # One row per DM thread, latest first.
59
+ def conversations(workspace_id: nil, platform: nil, account_id: nil, state: nil, q: nil, sort: nil,
60
+ page: 1, per_page: 25)
61
+ body = http.get(
62
+ '/inbox/conversations',
63
+ {
64
+ 'workspace_id' => workspace_id,
65
+ 'platform' => platform,
66
+ 'account_id' => account_id,
67
+ 'state' => state,
68
+ 'q' => q,
69
+ 'sort' => sort,
70
+ 'page' => page,
71
+ 'per_page' => per_page
72
+ }
73
+ )
74
+ page_of(InboxConversation, body)
75
+ end
76
+
77
+ def unread_count(workspace_id: nil)
78
+ body = http.get('/inbox/unread-count', { 'workspace_id' => workspace_id })
79
+ count = body.is_a?(Hash) ? body['count'] : nil
80
+ count.is_a?(Integer) ? count : 0
81
+ end
82
+
83
+ # Every active account, flagged with whether comments and DMs can be read for it.
84
+ def accounts(workspace_id: nil)
85
+ parse_list(InboxAccount, unwrap(http.get('/inbox/accounts', { 'workspace_id' => workspace_id })))
86
+ end
87
+
88
+ def platforms
89
+ parse_list(InboxPlatform, unwrap(http.get('/inbox/platforms')))
90
+ end
91
+
92
+ # Mark a whole comment thread or DM thread read. Returns how many items changed.
93
+ def mark_thread_read(workspace_id:, account_id:, post_external_id: nil, conversation_id: nil)
94
+ body = compact_nil(
95
+ 'workspace_id' => workspace_id,
96
+ 'account_id' => account_id,
97
+ 'post_external_id' => post_external_id,
98
+ 'conversation_id' => conversation_id
99
+ )
100
+ result = unwrap(http.post('/inbox/read', body))
101
+ updated = result.is_a?(Hash) ? result['updated'] : nil
102
+ updated.is_a?(Integer) ? updated : 0
103
+ end
104
+
105
+ # Poll every inbox-capable account in the workspace now.
106
+ def refresh(workspace_id:)
107
+ InboxRefreshResult.new(unwrap(http.post('/inbox/refresh', { 'workspace_id' => workspace_id })))
108
+ end
109
+
110
+ # Set the item's state to unread, read, resolved or snoozed. A snooze
111
+ # needs `snoozed_until` in the future.
112
+ def update(item_id, state:, snoozed_until: nil)
113
+ body = compact_nil('state' => state, 'snoozedUntil' => iso8601(snoozed_until))
114
+ InboxItem.new(unwrap(http.request(:patch, "/inbox/#{item_id}", json: body)))
115
+ end
116
+
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)))
127
+ end
128
+
129
+ def hide(item_id)
130
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/hide")))
131
+ end
132
+
133
+ def unhide(item_id)
134
+ InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unhide")))
135
+ end
136
+
137
+ # Delete the comment on the platform, or our own reply (that also needs `publish`).
138
+ def delete(item_id)
139
+ http.delete("/inbox/#{item_id}")
140
+ nil
141
+ end
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
+
201
+ # Replies an automation or the agent drafted that a person still has to send.
202
+ def approvals(workspace_id: nil)
203
+ parse_list(InboxApproval, unwrap(http.get('/inbox/approvals', { 'workspace_id' => workspace_id })))
204
+ end
205
+
206
+ # Send the draft, or `text` in its place.
207
+ def approve_reply(approval_id, text: nil)
208
+ as_hash(unwrap(http.post("/inbox/approvals/#{approval_id}/approve", compact_nil('text' => text))))
209
+ end
210
+
211
+ def reject_reply(approval_id)
212
+ as_hash(unwrap(http.post("/inbox/approvals/#{approval_id}/reject")))
213
+ end
214
+
215
+ private
216
+
217
+ def page_of(model, body)
218
+ items = parse_list(model, body.is_a?(Hash) ? body['data'] : body)
219
+ raw_meta = body.is_a?(Hash) ? body['meta'] : nil
220
+ Page.new(items: items, meta: InboxPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {}))
221
+ end
222
+ end
223
+ end
224
+ end
@@ -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