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.
@@ -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
@@ -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