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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +169 -0
- data/README.md +593 -3
- data/lib/fopost/client.rb +29 -1
- data/lib/fopost/errors.rb +25 -0
- data/lib/fopost/file_part.rb +73 -0
- data/lib/fopost/http/client.rb +36 -4
- data/lib/fopost/http/multipart.rb +38 -0
- data/lib/fopost/models.rb +1855 -28
- data/lib/fopost/platforms.rb +27 -0
- data/lib/fopost/resources/account_groups.rb +37 -0
- data/lib/fopost/resources/accounts.rb +445 -3
- data/lib/fopost/resources/activity.rb +27 -0
- data/lib/fopost/resources/ads.rb +910 -7
- data/lib/fopost/resources/analytics.rb +63 -0
- data/lib/fopost/resources/blog.rb +206 -0
- data/lib/fopost/resources/blogs.rb +115 -0
- data/lib/fopost/resources/broadcasts.rb +189 -0
- data/lib/fopost/resources/contacts.rb +136 -0
- data/lib/fopost/resources/google_ads.rb +346 -0
- data/lib/fopost/resources/google_business.rb +166 -0
- data/lib/fopost/resources/inbox.rb +73 -7
- data/lib/fopost/resources/knowledge.rb +74 -0
- data/lib/fopost/resources/media.rb +56 -0
- data/lib/fopost/resources/posts.rb +75 -4
- data/lib/fopost/resources/validate.rb +37 -0
- data/lib/fopost/resources/whatsapp.rb +255 -0
- data/lib/fopost/version.rb +1 -1
- data/lib/fopost/webhooks.rb +97 -0
- data/lib/fopost.rb +2 -0
- metadata +17 -1
data/lib/fopost/client.rb
CHANGED
|
@@ -3,12 +3,25 @@
|
|
|
3
3
|
require 'fopost/http/client'
|
|
4
4
|
require 'fopost/models'
|
|
5
5
|
require 'fopost/resources/base'
|
|
6
|
+
require 'fopost/resources/account_groups'
|
|
6
7
|
require 'fopost/resources/accounts'
|
|
7
8
|
require 'fopost/resources/ads'
|
|
9
|
+
require 'fopost/resources/analytics'
|
|
10
|
+
require 'fopost/resources/blog'
|
|
11
|
+
require 'fopost/resources/google_ads'
|
|
8
12
|
require 'fopost/resources/ai'
|
|
13
|
+
require 'fopost/resources/broadcasts'
|
|
14
|
+
require 'fopost/resources/contacts'
|
|
15
|
+
require 'fopost/resources/google_business'
|
|
9
16
|
require 'fopost/resources/inbox'
|
|
17
|
+
require 'fopost/resources/activity'
|
|
18
|
+
require 'fopost/resources/knowledge'
|
|
10
19
|
require 'fopost/resources/labels'
|
|
20
|
+
require 'fopost/resources/media'
|
|
11
21
|
require 'fopost/resources/posts'
|
|
22
|
+
require 'fopost/resources/blogs'
|
|
23
|
+
require 'fopost/resources/validate'
|
|
24
|
+
require 'fopost/resources/whatsapp'
|
|
12
25
|
require 'fopost/resources/workspaces'
|
|
13
26
|
|
|
14
27
|
module Fopost
|
|
@@ -23,7 +36,9 @@ module Fopost
|
|
|
23
36
|
class Client
|
|
24
37
|
DEFAULT_BASE_URL = HTTP::Client::DEFAULT_BASE_URL
|
|
25
38
|
|
|
26
|
-
attr_reader :posts, :accounts, :workspaces, :labels, :ai, :inbox, :
|
|
39
|
+
attr_reader :posts, :accounts, :account_groups, :workspaces, :labels, :ai, :inbox, :contacts, :broadcasts,
|
|
40
|
+
:sequences, :knowledge, :ads, :validate, :media, :activity, :google_business, :analytics,
|
|
41
|
+
:blog, :blogs, :whatsapp
|
|
27
42
|
|
|
28
43
|
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: HTTP::Client::DEFAULT_TIMEOUT,
|
|
29
44
|
max_retries: HTTP::Client::DEFAULT_MAX_RETRIES, transport: nil, sleeper: nil)
|
|
@@ -44,11 +59,24 @@ module Fopost
|
|
|
44
59
|
|
|
45
60
|
@posts = Resources::Posts.new(@http)
|
|
46
61
|
@accounts = Resources::Accounts.new(@http)
|
|
62
|
+
@account_groups = Resources::AccountGroups.new(@http)
|
|
47
63
|
@workspaces = Resources::Workspaces.new(@http)
|
|
48
64
|
@labels = Resources::Labels.new(@http)
|
|
49
65
|
@ai = Resources::Ai.new(@http)
|
|
50
66
|
@inbox = Resources::Inbox.new(@http)
|
|
67
|
+
@contacts = Resources::Contacts.new(@http)
|
|
68
|
+
@broadcasts = Resources::Broadcasts.new(@http)
|
|
69
|
+
@sequences = Resources::Sequences.new(@http)
|
|
70
|
+
@knowledge = Resources::Knowledge.new(@http)
|
|
51
71
|
@ads = Resources::Ads.new(@http)
|
|
72
|
+
@validate = Resources::Validate.new(@http)
|
|
73
|
+
@whatsapp = Resources::Whatsapp.new(@http)
|
|
74
|
+
@media = Resources::Media.new(@http)
|
|
75
|
+
@analytics = Resources::Analytics.new(@http)
|
|
76
|
+
@activity = Resources::Activity.new(@http)
|
|
77
|
+
@google_business = Resources::GoogleBusiness.new(@http)
|
|
78
|
+
@blog = Resources::Blog.new(@http)
|
|
79
|
+
@blogs = Resources::Blogs.new(@http)
|
|
52
80
|
end
|
|
53
81
|
|
|
54
82
|
def base_url
|
data/lib/fopost/errors.rb
CHANGED
|
@@ -56,6 +56,30 @@ module Fopost
|
|
|
56
56
|
# 404 — no such resource, or it is outside the key's reach.
|
|
57
57
|
class NotFoundError < Error; end
|
|
58
58
|
|
|
59
|
+
# 413 — the file is over the account's upload ceiling. {#max_file_bytes} is
|
|
60
|
+
# the largest file it may upload; {#upgrade_url} is where a bigger one is unlocked.
|
|
61
|
+
class FileTooLargeError < Error
|
|
62
|
+
def max_file_bytes
|
|
63
|
+
integer_field('max_file_bytes')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def file_bytes
|
|
67
|
+
integer_field('file_bytes')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def upgrade_url
|
|
71
|
+
value = body.is_a?(Hash) ? (body['upgrade_url'] || body[:upgrade_url]) : nil
|
|
72
|
+
value.is_a?(String) ? value : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def integer_field(name)
|
|
78
|
+
value = body.is_a?(Hash) ? (body[name] || body[name.to_sym]) : nil
|
|
79
|
+
value.is_a?(Integer) ? value : nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
59
83
|
# 429 — rate limit exceeded. {#retry_after} is in seconds when the API sends it.
|
|
60
84
|
class RateLimitError < Error
|
|
61
85
|
attr_reader :retry_after
|
|
@@ -74,6 +98,7 @@ module Fopost
|
|
|
74
98
|
402 => PaymentRequiredError,
|
|
75
99
|
403 => PermissionDeniedError,
|
|
76
100
|
404 => NotFoundError,
|
|
101
|
+
413 => FileTooLargeError,
|
|
77
102
|
422 => ValidationError,
|
|
78
103
|
429 => RateLimitError
|
|
79
104
|
}.freeze
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
# A file handed to `posts.create(files: ...)`: a path, an IO, or a Hash of
|
|
5
|
+
# `filename:` plus `io:` or `data:` and `mime_type:`.
|
|
6
|
+
class FilePart
|
|
7
|
+
MIME_TYPES = {
|
|
8
|
+
'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif',
|
|
9
|
+
'webp' => 'image/webp', 'heic' => 'image/heic', 'mp4' => 'video/mp4', 'mov' => 'video/quicktime',
|
|
10
|
+
'webm' => 'video/webm', 'm4v' => 'video/x-m4v', 'avi' => 'video/x-msvideo'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :filename, :mime_type
|
|
14
|
+
|
|
15
|
+
def self.from(value)
|
|
16
|
+
case value
|
|
17
|
+
when FilePart then value
|
|
18
|
+
when String then new(filename: File.basename(value), path: value)
|
|
19
|
+
when Hash then from_hash(value)
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "fopost: cannot read a file from #{value.inspect}" unless value.respond_to?(:read)
|
|
22
|
+
|
|
23
|
+
new(filename: value.respond_to?(:path) && value.path ? File.basename(value.path) : nil, io: value)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.from_hash(hash)
|
|
28
|
+
hash = hash.transform_keys(&:to_sym)
|
|
29
|
+
new(filename: hash[:filename], mime_type: hash[:mime_type], io: hash[:io], data: hash[:data])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(filename:, mime_type: nil, path: nil, io: nil, data: nil)
|
|
33
|
+
raise ArgumentError, 'fopost: a file needs a filename' if filename.nil? || filename.to_s.empty?
|
|
34
|
+
raise ArgumentError, "fopost: a file needs io: or data: (#{filename})" if path.nil? && io.nil? && data.nil?
|
|
35
|
+
|
|
36
|
+
@filename = filename.to_s
|
|
37
|
+
@path = path
|
|
38
|
+
@io = io
|
|
39
|
+
@data = data&.b
|
|
40
|
+
@mime_type = mime_type || guess_mime_type
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size
|
|
44
|
+
return @data.bytesize if @data
|
|
45
|
+
return File.size(@path) if @path
|
|
46
|
+
return @io.size if @io.respond_to?(:size) && @io.size
|
|
47
|
+
|
|
48
|
+
data.bytesize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The bytes, read once and kept.
|
|
52
|
+
def data
|
|
53
|
+
@data ||= (@path ? File.binread(@path) : @io.read.to_s.b)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The post media `type` the API expects for this file.
|
|
57
|
+
def media_type
|
|
58
|
+
return 'gif' if mime_type == 'image/gif'
|
|
59
|
+
return 'video' if mime_type.start_with?('video/')
|
|
60
|
+
|
|
61
|
+
'image'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def guess_mime_type
|
|
67
|
+
extension = File.extname(@filename).delete_prefix('.').downcase
|
|
68
|
+
MIME_TYPES.fetch(extension) do
|
|
69
|
+
raise ArgumentError, "fopost: cannot guess the mime type of #{@filename}; pass mime_type:"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/fopost/http/client.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'uri'
|
|
|
7
7
|
require 'fopost/errors'
|
|
8
8
|
require 'fopost/version'
|
|
9
9
|
require 'fopost/http/net_http_transport'
|
|
10
|
+
require 'fopost/http/multipart'
|
|
10
11
|
|
|
11
12
|
module Fopost
|
|
12
13
|
module HTTP
|
|
@@ -45,14 +46,21 @@ module Fopost
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Send a request, retrying on 429, and return the decoded body.
|
|
48
|
-
|
|
49
|
+
# `multipart` is `{ fields: {name => string}, parts: [Multipart::Part] }`.
|
|
50
|
+
def request(method, path, json: nil, params: nil, multipart: nil)
|
|
49
51
|
url = build_url(path, params)
|
|
50
|
-
|
|
52
|
+
request_headers = headers
|
|
53
|
+
body = nil
|
|
54
|
+
if multipart
|
|
55
|
+
body, request_headers['Content-Type'] = Multipart.encode(multipart[:fields] || {}, multipart[:parts] || [])
|
|
56
|
+
elsif !json.nil?
|
|
57
|
+
body = JSON.generate(json)
|
|
58
|
+
end
|
|
51
59
|
|
|
52
60
|
attempt = 0
|
|
53
61
|
loop do
|
|
54
62
|
attempt += 1
|
|
55
|
-
response = @transport.call(method: method.to_s.upcase, url: url, headers:
|
|
63
|
+
response = @transport.call(method: method.to_s.upcase, url: url, headers: request_headers, body: body)
|
|
56
64
|
|
|
57
65
|
if response.status == 429 && attempt < @max_retries
|
|
58
66
|
wait = retry_after_seconds(response)
|
|
@@ -76,10 +84,31 @@ module Fopost
|
|
|
76
84
|
request(:put, path, json: json)
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
def patch(path, json = nil)
|
|
88
|
+
request(:patch, path, json: json)
|
|
89
|
+
end
|
|
90
|
+
|
|
79
91
|
def delete(path, json = nil)
|
|
80
92
|
request(:delete, path, json: json)
|
|
81
93
|
end
|
|
82
94
|
|
|
95
|
+
def post_multipart(path, fields:, parts:)
|
|
96
|
+
request(:post, path, multipart: { fields: fields, parts: parts })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def put_multipart(path, fields:, parts:)
|
|
100
|
+
request(:put, path, multipart: { fields: fields, parts: parts })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# PUT raw bytes to a presigned URL. Sends only the headers given, never
|
|
104
|
+
# the API key, and returns nothing: the body is the storage backend's.
|
|
105
|
+
def put_raw(url, body, headers)
|
|
106
|
+
response = @transport.call(method: 'PUT', url: URI.parse(url.to_s), headers: headers, body: body)
|
|
107
|
+
return if response.success?
|
|
108
|
+
|
|
109
|
+
raise ErrorFactory.build(response.status, decode_body(response))
|
|
110
|
+
end
|
|
111
|
+
|
|
83
112
|
# Peel the `{"data": ...}` envelope the API wraps most responses in. Some
|
|
84
113
|
# endpoints (POST /posts, GET /posts/:id) return the resource bare, so the
|
|
85
114
|
# envelope comes off only when it is actually there.
|
|
@@ -96,7 +125,10 @@ module Fopost
|
|
|
96
125
|
query = (params || {}).compact
|
|
97
126
|
unless query.empty?
|
|
98
127
|
existing = uri.query.to_s
|
|
99
|
-
|
|
128
|
+
# An Array repeats the bare parameter, which is how the API reads a
|
|
129
|
+
# multi-valued filter like `daily_metrics`.
|
|
130
|
+
pairs = query.flat_map { |k, v| Array(v).map { |item| [k.to_s, item.to_s] } }
|
|
131
|
+
encoded = URI.encode_www_form(pairs)
|
|
100
132
|
uri.query = existing.empty? ? encoded : "#{existing}&#{encoded}"
|
|
101
133
|
end
|
|
102
134
|
uri
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Fopost
|
|
6
|
+
module HTTP
|
|
7
|
+
# Encodes a multipart/form-data body from string fields and file parts.
|
|
8
|
+
module Multipart
|
|
9
|
+
Part = Struct.new(:name, :filename, :mime_type, :data, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Returns `[body, content_type]`; `body` is a binary String.
|
|
12
|
+
def self.encode(fields, parts)
|
|
13
|
+
boundary = "fopost#{SecureRandom.hex(16)}"
|
|
14
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
15
|
+
|
|
16
|
+
fields.each do |name, value|
|
|
17
|
+
body << "--#{boundary}\r\n"
|
|
18
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
|
|
19
|
+
body << value.to_s.b << "\r\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
parts.each do |part|
|
|
23
|
+
body << "--#{boundary}\r\n"
|
|
24
|
+
body << "Content-Disposition: form-data; name=\"#{part.name}\"; filename=\"#{quote(part.filename)}\"\r\n"
|
|
25
|
+
body << "Content-Type: #{part.mime_type}\r\n\r\n"
|
|
26
|
+
body << part.data.b << "\r\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
body << "--#{boundary}--\r\n"
|
|
30
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.quote(value)
|
|
34
|
+
value.to_s.gsub(/["\r\n]/, '"' => '%22', "\r" => '', "\n" => '')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|