fopost 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +250 -0
- data/lib/fopost/client.rb +63 -0
- data/lib/fopost/errors.rb +105 -0
- data/lib/fopost/http/client.rb +149 -0
- data/lib/fopost/http/net_http_transport.rb +55 -0
- data/lib/fopost/http/response.rb +24 -0
- data/lib/fopost/http/transport.rb +21 -0
- data/lib/fopost/model.rb +101 -0
- data/lib/fopost/models.rb +208 -0
- data/lib/fopost/platforms.rb +49 -0
- data/lib/fopost/resources/accounts.rb +23 -0
- data/lib/fopost/resources/ai.rb +52 -0
- data/lib/fopost/resources/base.rb +49 -0
- data/lib/fopost/resources/labels.rb +12 -0
- data/lib/fopost/resources/posts.rb +185 -0
- data/lib/fopost/resources/workspaces.rb +16 -0
- data/lib/fopost/unset.rb +10 -0
- data/lib/fopost/version.rb +5 -0
- data/lib/fopost.rb +36 -0
- metadata +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module HTTP
|
|
5
|
+
# The seam the SDK sends requests through.
|
|
6
|
+
#
|
|
7
|
+
# Implement `call` and pass an instance as `transport:` to swap in your own
|
|
8
|
+
# HTTP stack, or to stub the network in tests.
|
|
9
|
+
#
|
|
10
|
+
# class MyTransport
|
|
11
|
+
# def call(method:, url:, headers:, body:)
|
|
12
|
+
# Fopost::HTTP::Response.new(status: 200, body: '{}')
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module Transport
|
|
16
|
+
def call(method:, url:, headers:, body:)
|
|
17
|
+
raise NotImplementedError, 'a transport must implement #call'
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/fopost/model.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Fopost
|
|
6
|
+
# Base for every response model.
|
|
7
|
+
#
|
|
8
|
+
# The API is not consistent about its wire casing — posts come back
|
|
9
|
+
# snake_case, accounts camelCase, workspaces a mix — so every key is
|
|
10
|
+
# normalised to a snake_case symbol before it is read. Unknown keys are kept
|
|
11
|
+
# on {#raw} rather than dropped, so a server-side addition never breaks a
|
|
12
|
+
# client.
|
|
13
|
+
class Model
|
|
14
|
+
class << self
|
|
15
|
+
def attribute_types
|
|
16
|
+
@attribute_types ||= superclass.respond_to?(:attribute_types) ? superclass.attribute_types.dup : {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `type` is nil (pass through), :time, :hash, a Model subclass, or a
|
|
20
|
+
# one-element array holding a Model subclass.
|
|
21
|
+
def attribute(name, type = nil)
|
|
22
|
+
attribute_types[name] = type
|
|
23
|
+
define_method(name) { @attributes[name] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def snake(key)
|
|
27
|
+
key.to_s
|
|
28
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
29
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
30
|
+
.downcase
|
|
31
|
+
.to_sym
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize(data)
|
|
35
|
+
return {} unless data.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
data.each_with_object({}) { |(key, value), out| out[snake(key)] = value }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def coerce(type, value)
|
|
41
|
+
case type
|
|
42
|
+
when nil then value
|
|
43
|
+
when :time then parse_time(value)
|
|
44
|
+
when :hash then value.is_a?(Hash) ? value : {}
|
|
45
|
+
when Array
|
|
46
|
+
member = type.first
|
|
47
|
+
value.is_a?(Array) ? value.map { |item| member.new(item) } : []
|
|
48
|
+
else
|
|
49
|
+
value.is_a?(Hash) ? type.new(value) : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parse_time(value)
|
|
54
|
+
case value
|
|
55
|
+
when Time then value
|
|
56
|
+
when String then Time.iso8601(value) rescue (Time.parse(value) rescue nil)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The decoded body exactly as the API sent it.
|
|
62
|
+
attr_reader :raw
|
|
63
|
+
|
|
64
|
+
def initialize(data = {})
|
|
65
|
+
@raw = data.is_a?(Hash) ? data : {}
|
|
66
|
+
normalized = self.class.normalize(@raw)
|
|
67
|
+
@attributes = {}
|
|
68
|
+
self.class.attribute_types.each do |name, type|
|
|
69
|
+
@attributes[name] = self.class.coerce(type, normalized[name])
|
|
70
|
+
end
|
|
71
|
+
@normalized = normalized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Read a field the SDK does not model yet, by either wire spelling.
|
|
75
|
+
def [](key)
|
|
76
|
+
name = self.class.snake(key)
|
|
77
|
+
return @attributes[name] if @attributes.key?(name)
|
|
78
|
+
|
|
79
|
+
@normalized[name]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_h
|
|
83
|
+
@attributes.dup
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ==(other)
|
|
87
|
+
other.class == self.class && other.raw == raw
|
|
88
|
+
end
|
|
89
|
+
alias eql? ==
|
|
90
|
+
|
|
91
|
+
def hash
|
|
92
|
+
[self.class, raw].hash
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def inspect
|
|
96
|
+
shown = @attributes.reject { |_, value| value.nil? || value == [] || value == {} }
|
|
97
|
+
fields = shown.map { |name, value| "#{name}=#{value.inspect}" }.join(' ')
|
|
98
|
+
"#<#{self.class.name}#{" #{fields}" unless fields.empty?}>"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fopost/model'
|
|
4
|
+
|
|
5
|
+
module Fopost
|
|
6
|
+
class MediaItem < Model
|
|
7
|
+
attribute :type
|
|
8
|
+
attribute :name
|
|
9
|
+
attribute :url
|
|
10
|
+
attribute :size
|
|
11
|
+
attribute :alt
|
|
12
|
+
attribute :thumbnail
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One block of a post. A thread is several blocks in order.
|
|
16
|
+
class ContentBlock < Model
|
|
17
|
+
attribute :id
|
|
18
|
+
attribute :text
|
|
19
|
+
attribute :media, [MediaItem]
|
|
20
|
+
attribute :position
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A connected social account. Named so it does not read as a user account.
|
|
24
|
+
class SocialAccount < Model
|
|
25
|
+
attribute :id
|
|
26
|
+
attribute :workspace_id
|
|
27
|
+
attribute :platform
|
|
28
|
+
attribute :username
|
|
29
|
+
attribute :name
|
|
30
|
+
attribute :avatar
|
|
31
|
+
attribute :active
|
|
32
|
+
attribute :is_primary
|
|
33
|
+
attribute :health_status
|
|
34
|
+
attribute :last_health_check, :time
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# An account a post is targeted at, plus its per-account delivery state.
|
|
38
|
+
class PostAccount < Model
|
|
39
|
+
attribute :id
|
|
40
|
+
attribute :platform
|
|
41
|
+
attribute :username
|
|
42
|
+
attribute :name
|
|
43
|
+
attribute :avatar
|
|
44
|
+
attribute :publish_status
|
|
45
|
+
attribute :posted_at, :time
|
|
46
|
+
attribute :platform_post_id
|
|
47
|
+
attribute :external_url
|
|
48
|
+
attribute :error_code
|
|
49
|
+
attribute :error_message
|
|
50
|
+
attribute :attempts
|
|
51
|
+
attribute :max_attempts
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Label < Model
|
|
55
|
+
attribute :id
|
|
56
|
+
attribute :name
|
|
57
|
+
attribute :color
|
|
58
|
+
attribute :workspace, :hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Post < Model
|
|
62
|
+
attribute :id
|
|
63
|
+
attribute :workspace_id
|
|
64
|
+
attribute :status
|
|
65
|
+
attribute :content_type
|
|
66
|
+
attribute :schedule_at, :time
|
|
67
|
+
attribute :title
|
|
68
|
+
attribute :summary
|
|
69
|
+
attribute :repeatable
|
|
70
|
+
attribute :repeatable_times
|
|
71
|
+
attribute :repeatable_gap
|
|
72
|
+
attribute :repeatable_gap_unit
|
|
73
|
+
attribute :remaining_posts
|
|
74
|
+
attribute :auto_plug
|
|
75
|
+
attribute :auto_plug_content
|
|
76
|
+
attribute :approved_at, :time
|
|
77
|
+
attribute :rejection_reason
|
|
78
|
+
attribute :content, [ContentBlock]
|
|
79
|
+
attribute :accounts, [PostAccount]
|
|
80
|
+
attribute :labels, [Label]
|
|
81
|
+
attribute :settings, :hash
|
|
82
|
+
attribute :created_at, :time
|
|
83
|
+
attribute :updated_at, :time
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class Workspace < Model
|
|
87
|
+
attribute :id
|
|
88
|
+
attribute :name
|
|
89
|
+
attribute :slug
|
|
90
|
+
attribute :type
|
|
91
|
+
attribute :logo
|
|
92
|
+
attribute :website
|
|
93
|
+
attribute :timezone
|
|
94
|
+
attribute :country
|
|
95
|
+
attribute :description
|
|
96
|
+
attribute :language
|
|
97
|
+
attribute :require_approval
|
|
98
|
+
attribute :ai_alt_text_enabled
|
|
99
|
+
attribute :brand_color
|
|
100
|
+
attribute :role
|
|
101
|
+
attribute :created_at, :time
|
|
102
|
+
attribute :accounts, [SocialAccount]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One post-to-account delivery attempt.
|
|
106
|
+
class Delivery < Model
|
|
107
|
+
attribute :id
|
|
108
|
+
attribute :account_id
|
|
109
|
+
attribute :status
|
|
110
|
+
attribute :platform
|
|
111
|
+
attribute :username
|
|
112
|
+
attribute :account_name
|
|
113
|
+
attribute :error_code
|
|
114
|
+
attribute :error_message
|
|
115
|
+
attribute :attempts
|
|
116
|
+
attribute :max_attempts
|
|
117
|
+
attribute :scheduled_publish_at, :time
|
|
118
|
+
attribute :delay_reason
|
|
119
|
+
attribute :delay_message
|
|
120
|
+
attribute :posted_at, :time
|
|
121
|
+
attribute :last_attempt_at, :time
|
|
122
|
+
attribute :platform_post_id
|
|
123
|
+
attribute :external_url
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class PageMeta < Model
|
|
127
|
+
attribute :current_page
|
|
128
|
+
attribute :per_page
|
|
129
|
+
attribute :total
|
|
130
|
+
attribute :last_page
|
|
131
|
+
attribute :from
|
|
132
|
+
attribute :to
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# One page of a list endpoint: its items plus the pagination meta.
|
|
136
|
+
class Page
|
|
137
|
+
include Enumerable
|
|
138
|
+
|
|
139
|
+
attr_reader :items, :meta
|
|
140
|
+
|
|
141
|
+
def initialize(items: [], meta: PageMeta.new)
|
|
142
|
+
@items = items
|
|
143
|
+
@meta = meta
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def each(&block)
|
|
147
|
+
return items.each unless block
|
|
148
|
+
|
|
149
|
+
items.each(&block)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def [](index)
|
|
154
|
+
items[index]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def size
|
|
158
|
+
items.size
|
|
159
|
+
end
|
|
160
|
+
alias length size
|
|
161
|
+
alias count size
|
|
162
|
+
|
|
163
|
+
def empty?
|
|
164
|
+
items.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def inspect
|
|
168
|
+
"#<Fopost::Page items=#{items.size} total=#{meta.total.inspect}>"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Credits charged by one AI call, and what is left afterwards.
|
|
173
|
+
class AiCredits < Model
|
|
174
|
+
attribute :charged
|
|
175
|
+
attribute :remaining
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class AiCreditBalance < Model
|
|
179
|
+
attribute :credits_remaining
|
|
180
|
+
attribute :credits_used
|
|
181
|
+
attribute :credits_total
|
|
182
|
+
attribute :period_start, :time
|
|
183
|
+
attribute :period_end, :time
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class CaptionResult < Model
|
|
187
|
+
attribute :caption
|
|
188
|
+
attribute :credits, AiCredits
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class RewriteVariant < Model
|
|
192
|
+
attribute :platform
|
|
193
|
+
attribute :content
|
|
194
|
+
attribute :credits
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class RewriteResult < Model
|
|
198
|
+
attribute :results, [RewriteVariant]
|
|
199
|
+
attribute :credits, AiCredits
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class RepurposeResult < Model
|
|
203
|
+
attribute :url
|
|
204
|
+
attribute :title
|
|
205
|
+
attribute :posts, :hash
|
|
206
|
+
attribute :credits, AiCredits
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
# Every platform the API can publish to. Model fields stay plain strings, so a
|
|
5
|
+
# platform added server-side still parses on an older SDK.
|
|
6
|
+
PLATFORMS = %w[
|
|
7
|
+
twitter
|
|
8
|
+
linkedin
|
|
9
|
+
facebook
|
|
10
|
+
instagram
|
|
11
|
+
instagram-business
|
|
12
|
+
telegram
|
|
13
|
+
twitch
|
|
14
|
+
discord
|
|
15
|
+
slack
|
|
16
|
+
reddit
|
|
17
|
+
pinterest
|
|
18
|
+
tumblr
|
|
19
|
+
dribbble
|
|
20
|
+
mewe
|
|
21
|
+
tiktok
|
|
22
|
+
youtube
|
|
23
|
+
bluesky
|
|
24
|
+
threads
|
|
25
|
+
mastodon
|
|
26
|
+
lemmy
|
|
27
|
+
devto
|
|
28
|
+
hashnode
|
|
29
|
+
medium
|
|
30
|
+
substack
|
|
31
|
+
google-business
|
|
32
|
+
kick
|
|
33
|
+
listmonk
|
|
34
|
+
wordpress
|
|
35
|
+
nostr
|
|
36
|
+
whop
|
|
37
|
+
skool
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
POST_STATUSES = %w[
|
|
41
|
+
draft
|
|
42
|
+
pending_approval
|
|
43
|
+
scheduled
|
|
44
|
+
publishing
|
|
45
|
+
published
|
|
46
|
+
failed
|
|
47
|
+
cancelled
|
|
48
|
+
].freeze
|
|
49
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.accounts` — the social accounts connected to a workspace.
|
|
6
|
+
class Accounts < Base
|
|
7
|
+
# Connected accounts, across every workspace unless one is named.
|
|
8
|
+
def list(workspace_id: nil)
|
|
9
|
+
# This endpoint reads a camelCase query param; posts and labels use snake.
|
|
10
|
+
parse_list(SocialAccount, unwrap(http.get('/accounts', { 'workspaceId' => workspace_id })))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(account_id)
|
|
14
|
+
SocialAccount.new(unwrap(http.get("/accounts/#{account_id}")))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Token validity and last-check detail for one account.
|
|
18
|
+
def health(account_id)
|
|
19
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/health")))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.ai` — caption assist, per-platform rewriting, and blog fan-out.
|
|
6
|
+
#
|
|
7
|
+
# Every call spends AI credits. Check the balance with {#credits}; a
|
|
8
|
+
# {Fopost::PaymentRequiredError} means the plan has none left.
|
|
9
|
+
class Ai < Base
|
|
10
|
+
# Credits remaining, used, and total for the current billing period.
|
|
11
|
+
def credits
|
|
12
|
+
AiCreditBalance.new(unwrap(http.get('/ai/credits')))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate_caption(current_caption: nil, image_urls: nil, platforms: nil,
|
|
16
|
+
char_limit: nil, workspace_id: nil, brand_voice_id: nil)
|
|
17
|
+
body = compact_nil(
|
|
18
|
+
'current_caption' => current_caption,
|
|
19
|
+
'image_urls' => image_urls&.to_a,
|
|
20
|
+
'platforms' => platforms&.to_a,
|
|
21
|
+
'char_limit' => char_limit,
|
|
22
|
+
'workspace_id' => workspace_id,
|
|
23
|
+
'brand_voice_id' => brand_voice_id
|
|
24
|
+
)
|
|
25
|
+
CaptionResult.new(unwrap(http.post('/ai/generate-caption', body)))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rewrite one draft for each target platform. Costs 1 credit per platform.
|
|
29
|
+
def rewrite(content:, platforms:, tone: nil, workspace_id: nil, brand_voice_id: nil)
|
|
30
|
+
body = compact_nil(
|
|
31
|
+
'content' => content,
|
|
32
|
+
'platforms' => platforms.to_a,
|
|
33
|
+
'tone' => tone,
|
|
34
|
+
'workspace_id' => workspace_id,
|
|
35
|
+
'brand_voice_id' => brand_voice_id
|
|
36
|
+
)
|
|
37
|
+
RewriteResult.new(unwrap(http.post('/ai/rewrite', body)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Turn an article URL into a post for each platform, in one call.
|
|
41
|
+
def repurpose_url(url:, platforms:, workspace_id: nil, brand_voice_id: nil)
|
|
42
|
+
body = compact_nil(
|
|
43
|
+
'url' => url,
|
|
44
|
+
'platforms' => platforms.to_a,
|
|
45
|
+
'workspace_id' => workspace_id,
|
|
46
|
+
'brand_voice_id' => brand_voice_id
|
|
47
|
+
)
|
|
48
|
+
RepurposeResult.new(unwrap(http.post('/ai/repurpose-url', body)))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'fopost/unset'
|
|
5
|
+
|
|
6
|
+
module Fopost
|
|
7
|
+
module Resources
|
|
8
|
+
class Base
|
|
9
|
+
def initialize(http)
|
|
10
|
+
@http = http
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :http
|
|
16
|
+
|
|
17
|
+
def unwrap(body)
|
|
18
|
+
Fopost::HTTP::Client.unwrap(body)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse_list(model, data)
|
|
22
|
+
data.is_a?(Array) ? data.map { |item| model.new(item) } : []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def as_hash(body)
|
|
26
|
+
body.is_a?(Hash) ? body : { 'data' => body }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Strip the keys the caller never passed, so a PUT stays a partial update.
|
|
30
|
+
def compact_unset(body)
|
|
31
|
+
body.reject { |_, value| UNSET.equal?(value) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def compact_nil(body)
|
|
35
|
+
body.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def iso8601(value)
|
|
39
|
+
case value
|
|
40
|
+
when nil then nil
|
|
41
|
+
when String then value
|
|
42
|
+
when Time then value.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
43
|
+
else
|
|
44
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.labels` — workspace labels you can attach to posts.
|
|
6
|
+
class Labels < Base
|
|
7
|
+
def list(workspace_id: nil)
|
|
8
|
+
parse_list(Label, unwrap(http.get('/labels', { 'workspace_id' => workspace_id })))
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.posts` — create, schedule, publish, and inspect posts.
|
|
6
|
+
class Posts < Base
|
|
7
|
+
# One page of posts. The result is Enumerable over its items.
|
|
8
|
+
def list(workspace_id: nil, status: nil, search: nil, page: 1, per_page: 30, sort: nil)
|
|
9
|
+
body = http.get(
|
|
10
|
+
'/posts',
|
|
11
|
+
{
|
|
12
|
+
'workspace_id' => workspace_id,
|
|
13
|
+
'status' => status,
|
|
14
|
+
'search' => search,
|
|
15
|
+
'page' => page,
|
|
16
|
+
'per_page' => per_page,
|
|
17
|
+
'sort' => sort
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
items = parse_list(Post, body.is_a?(Hash) ? body['data'] : body)
|
|
22
|
+
raw_meta = body.is_a?(Hash) ? body['meta'] : nil
|
|
23
|
+
Page.new(items: items, meta: PageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {}))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Walk every matching post, fetching one page at a time.
|
|
27
|
+
def each(workspace_id: nil, status: nil, search: nil, per_page: 30, sort: nil, start_page: 1, &block)
|
|
28
|
+
unless block
|
|
29
|
+
return enum_for(:each, workspace_id: workspace_id, status: status, search: search,
|
|
30
|
+
per_page: per_page, sort: sort, start_page: start_page)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
each_page(workspace_id: workspace_id, status: status, search: search,
|
|
34
|
+
per_page: per_page, sort: sort, start_page: start_page) do |page|
|
|
35
|
+
page.items.each(&block)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The same walk as {#each}, but yields whole pages so the meta stays reachable.
|
|
40
|
+
def each_page(workspace_id: nil, status: nil, search: nil, per_page: 30, sort: nil, start_page: 1)
|
|
41
|
+
unless block_given?
|
|
42
|
+
return enum_for(:each_page, workspace_id: workspace_id, status: status, search: search,
|
|
43
|
+
per_page: per_page, sort: sort, start_page: start_page)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
page_number = start_page
|
|
47
|
+
loop do
|
|
48
|
+
page = list(workspace_id: workspace_id, status: status, search: search,
|
|
49
|
+
page: page_number, per_page: per_page, sort: sort)
|
|
50
|
+
return if page.items.empty?
|
|
51
|
+
|
|
52
|
+
yield page
|
|
53
|
+
|
|
54
|
+
last_page = page.meta.last_page
|
|
55
|
+
return if last_page && page_number >= last_page
|
|
56
|
+
return if last_page.nil? && page.items.size < per_page
|
|
57
|
+
|
|
58
|
+
page_number += 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get(post_id)
|
|
63
|
+
Post.new(unwrap(http.get("/posts/#{post_id}")))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Create a draft or a scheduled post.
|
|
67
|
+
#
|
|
68
|
+
# `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)
|
|
72
|
+
body = {
|
|
73
|
+
'workspace_id' => workspace_id,
|
|
74
|
+
'status' => status,
|
|
75
|
+
'content' => normalize_content(content),
|
|
76
|
+
'accounts' => normalize_accounts(accounts)
|
|
77
|
+
}
|
|
78
|
+
body.merge!(
|
|
79
|
+
compact_nil(
|
|
80
|
+
'schedule_at' => iso8601(schedule_at),
|
|
81
|
+
'labels' => labels&.to_a,
|
|
82
|
+
'title' => title,
|
|
83
|
+
'summary' => summary,
|
|
84
|
+
'content_type' => content_type,
|
|
85
|
+
'settings' => settings
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
body.merge!(stringify(extra))
|
|
89
|
+
|
|
90
|
+
Post.new(unwrap(http.post('/posts', body)))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Partial update — only the fields you pass are sent. Pass an explicit nil
|
|
94
|
+
# to clear a field.
|
|
95
|
+
def update(post_id, content: UNSET, accounts: UNSET, status: UNSET, schedule_at: UNSET,
|
|
96
|
+
labels: UNSET, title: UNSET, summary: UNSET, content_type: UNSET,
|
|
97
|
+
settings: UNSET, **extra)
|
|
98
|
+
body = compact_unset(
|
|
99
|
+
'content' => UNSET.equal?(content) ? UNSET : normalize_content(content),
|
|
100
|
+
'accounts' => UNSET.equal?(accounts) ? UNSET : normalize_accounts(accounts),
|
|
101
|
+
'status' => status,
|
|
102
|
+
'schedule_at' => UNSET.equal?(schedule_at) ? UNSET : iso8601(schedule_at),
|
|
103
|
+
'labels' => UNSET.equal?(labels) ? UNSET : labels&.to_a,
|
|
104
|
+
'title' => title,
|
|
105
|
+
'summary' => summary,
|
|
106
|
+
'content_type' => content_type,
|
|
107
|
+
'settings' => settings
|
|
108
|
+
)
|
|
109
|
+
body.merge!(stringify(extra))
|
|
110
|
+
|
|
111
|
+
Post.new(unwrap(http.put("/posts/#{post_id}", body)))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def delete(post_id)
|
|
115
|
+
http.delete("/posts/#{post_id}")
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Queue the post for immediate delivery to its accounts.
|
|
120
|
+
def publish(post_id)
|
|
121
|
+
as_hash(unwrap(http.post("/posts/#{post_id}/publish")))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cancel(post_id)
|
|
125
|
+
as_hash(unwrap(http.post("/posts/#{post_id}/cancel")))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Retry the deliveries that failed, leaving the successful ones alone.
|
|
129
|
+
def retry(post_id)
|
|
130
|
+
as_hash(unwrap(http.post("/posts/#{post_id}/retry")))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Per-account blockers and advisory content signals, without publishing.
|
|
134
|
+
def preflight(post_id)
|
|
135
|
+
as_hash(unwrap(http.post("/posts/#{post_id}/preflight")))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deliveries(post_id)
|
|
139
|
+
parse_list(Delivery, unwrap(http.get("/posts/#{post_id}/deliveries")))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Accept a bare string, one block, or an array of either.
|
|
145
|
+
def normalize_content(content)
|
|
146
|
+
blocks = content.is_a?(Array) ? content : [content]
|
|
147
|
+
|
|
148
|
+
blocks.map do |block|
|
|
149
|
+
case block
|
|
150
|
+
when String then { 'text' => block }
|
|
151
|
+
when ContentBlock
|
|
152
|
+
{
|
|
153
|
+
'text' => block.text,
|
|
154
|
+
'media' => block.media.map { |item| compact_nil(stringify(item.to_h)) }
|
|
155
|
+
}
|
|
156
|
+
when Hash then compact_nil(stringify(block))
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError, "fopost: cannot read a content block from #{block.inspect}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The API takes bare account ids; also accept account objects or {"id" => ...}.
|
|
164
|
+
def normalize_accounts(accounts)
|
|
165
|
+
Array(accounts).map do |account|
|
|
166
|
+
case account
|
|
167
|
+
when String then account
|
|
168
|
+
when SocialAccount then account.id
|
|
169
|
+
when Hash
|
|
170
|
+
id = account['id'] || account[:id]
|
|
171
|
+
raise ArgumentError, "fopost: cannot read an account id from #{account.inspect}" unless id.is_a?(String)
|
|
172
|
+
|
|
173
|
+
id
|
|
174
|
+
else
|
|
175
|
+
raise ArgumentError, "fopost: cannot read an account id from #{account.inspect}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stringify(hash)
|
|
181
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|