posthubify 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6aba91ab279c1953076139305c63fbf1b74818368796f6f9131d0179a745b0c3
4
+ data.tar.gz: 041db11a40a45c60ccedd45033e2d6c0f398de01f77c99143444c7223d08d521
5
+ SHA512:
6
+ metadata.gz: 1f6f687df94c5a45fd36b5d0018dea79d8c14e3e78edf08a6729e3481e67c0e6cf34541d76a345493f1cc9a905ad83724dd05998365bffd6f17007ce8fa13226
7
+ data.tar.gz: 44c0b1e60be2554d52c9d9ec8e4003aed83e5556a5aa62354ecdd19b8479f8c4e9d7e9e29bc794fd04b2921f7b72ea95d4b13b4d33d8afee065ff08e549801b0
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # PostHubify Ruby SDK
2
+
3
+ The official Ruby client for the PostHubify `/v1` API — a single gem for social media
4
+ publishing, inbox, contacts, analytics, ads, and telecom (SMS/OTP).
5
+
6
+ - **Zero runtime dependencies** — Ruby standard library only (`net/http`).
7
+ - **Full surface** — covers all 190+ operations of the `/v1` spec one-to-one (verified by a contract test).
8
+ - Ruby 2.6+
9
+
10
+ ## Installation
11
+
12
+ ```ruby
13
+ gem install posthubify
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```ruby
19
+ require 'posthubify'
20
+
21
+ ph = Posthubify::Client.new(api_key: 'sk_...', base_url: 'https://api.posthubify.com/v1')
22
+
23
+ # Connection test
24
+ ph.ping # => {"pong"=>true, "version"=>"v1", ...}
25
+
26
+ # Create a post
27
+ ph.posts.create({ 'content' => 'Hello world!', 'accountIds' => ['acc_1'] })
28
+
29
+ # Safe retry (Idempotency-Key — posts/sms/otp/ads/broadcasts)
30
+ require 'securerandom'
31
+ key = SecureRandom.uuid
32
+ ph.posts.create({ 'content' => 'Launch!', 'accountIds' => ['acc_1'] }, idempotency_key: key)
33
+
34
+ # Inbox analytics
35
+ ph.inbox_analytics.volume(from_date: '2026-06-01', to_date: '2026-06-14')
36
+
37
+ # Error handling
38
+ begin
39
+ ph.posts.get('missing')
40
+ rescue Posthubify::Error => e
41
+ puts [e.status, e.code, e.message].inspect # [404, nil, "..."]
42
+ end
43
+ ```
44
+
45
+ ## Resource groups
46
+
47
+ The exact same surface as the Node/Python SDKs, with snake_case accessors:
48
+
49
+ `profiles` · `accounts` · `posts` · `media` · `tools` · `ads` · `insights` ·
50
+ `platform_analytics` · `inbox_analytics` · `inbox` · `comments` · `reviews` ·
51
+ `contacts` · `automations` · `broadcasts` · `sequences` · `webhooks` · `api_keys` ·
52
+ `queue` (`.schedules`) · `account_groups` · `engagement` (`.x`) · `users` ·
53
+ `invite_tokens` · `numbers` · `senders` · `sms` · `otp`
54
+
55
+ Top level: `ph.openapi`, `ph.ping`, `ph.me`, `ph.analytics`, `ph.analytics_posts`,
56
+ `ph.analytics_timeseries`, `ph.logs`, `ph.usage`.
57
+
58
+ ## Media upload
59
+
60
+ ```ruby
61
+ content = File.binread('logo.png')
62
+ asset = ph.media.upload(content, 'logo.png') # => {"assetId"=>..., "url"=>...}
63
+ ph.posts.create({ 'content' => 'New!', 'accountIds' => ['acc_1'], 'mediaAssetId' => asset['assetId'] })
64
+ ```
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ ruby packages/ruby-sdk/test/test_contract.rb # SDK ↔ spec contract test
70
+ ```
71
+
72
+ The contract test verifies that the endpoint path produced by every SDK method is defined in the OpenAPI spec.
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthubify
4
+ # PostHubify /v1 API client. All resource groups are exposed as snake_case readers.
5
+ class Client
6
+ attr_reader :profiles, :accounts, :posts, :media, :tools, :ads, :insights,
7
+ :platform_analytics, :inbox_analytics, :inbox, :comments, :reviews,
8
+ :contacts, :automations, :comment_automations, :workflows, :broadcasts, :sequences, :webhooks, :api_keys,
9
+ :queue, :account_groups, :engagement, :users, :invite_tokens,
10
+ :numbers, :senders, :sms, :otp,
11
+ :custom_fields, :whatsapp, :gmb, :discord, :discovery
12
+
13
+ # transport: injectable for tests (Node opts.fetch equivalent).
14
+ def initialize(api_key: nil, base_url: Transport::DEFAULT_BASE, timeout: 30, transport: nil)
15
+ @http = transport || Transport.new(api_key: api_key, base_url: base_url, timeout: timeout)
16
+
17
+ @profiles = ProfilesResource.new(@http)
18
+ @accounts = AccountsResource.new(@http)
19
+ @posts = PostsResource.new(@http)
20
+ @media = MediaResource.new(@http)
21
+ @tools = ToolsResource.new(@http)
22
+ @ads = AdsResource.new(@http)
23
+ @insights = InsightsResource.new(@http)
24
+ @platform_analytics = PlatformAnalyticsResource.new(@http)
25
+ @inbox_analytics = InboxAnalyticsResource.new(@http)
26
+ @inbox = InboxResource.new(@http)
27
+ @comments = CommentsResource.new(@http)
28
+ @reviews = ReviewsResource.new(@http)
29
+ @contacts = ContactsResource.new(@http)
30
+ @automations = AutomationsResource.new(@http)
31
+ @comment_automations = CommentAutomationsResource.new(@http)
32
+ @workflows = WorkflowsResource.new(@http)
33
+ @broadcasts = BroadcastsResource.new(@http)
34
+ @sequences = SequencesResource.new(@http)
35
+ @webhooks = WebhooksResource.new(@http)
36
+ @api_keys = ApiKeysResource.new(@http)
37
+ @queue = QueueResource.new(@http)
38
+ @account_groups = AccountGroupsResource.new(@http)
39
+ @engagement = EngagementResource.new(@http)
40
+ @users = UsersResource.new(@http)
41
+ @invite_tokens = InviteTokensResource.new(@http)
42
+ @numbers = NumbersResource.new(@http)
43
+ @senders = SendersResource.new(@http)
44
+ @sms = SmsResource.new(@http)
45
+ @otp = OtpResource.new(@http)
46
+ @custom_fields = CustomFieldsResource.new(@http)
47
+ @whatsapp = WhatsAppResource.new(@http)
48
+ @gmb = GmbResource.new(@http)
49
+ @discord = DiscordResource.new(@http)
50
+ @discovery = DiscoveryResource.new(@http)
51
+ end
52
+
53
+ # --- top-level endpoints ---
54
+ def openapi
55
+ @http.req('GET', '/openapi.json')
56
+ end
57
+
58
+ def ping
59
+ @http.data('GET', '/ping')
60
+ end
61
+
62
+ def me
63
+ @http.data('GET', '/me')
64
+ end
65
+
66
+ # AI content generation (F1) — command → per-platform draft + variants (multi-language).
67
+ def generate(command, platforms, variants: nil, context: nil, max_chars: nil)
68
+ @http.data('POST', '/generate', body: {
69
+ 'command' => command, 'platforms' => platforms, 'variants' => variants,
70
+ 'context' => context, 'maxChars' => max_chars,
71
+ })
72
+ end
73
+
74
+ # Subtitle generation (F4) — text → timed SRT/VTT (pure conversion, no AI/charge; a read key is enough).
75
+ # @param text [String] text to turn into subtitles (≤20000 characters)
76
+ # @param format [String] 'srt' or 'vtt' (default 'srt')
77
+ # @param duration_sec [Float, nil] if given, cues are distributed proportionally over this duration (audio-synced; ≤86400)
78
+ # @param max_chars_per_line [Integer, nil] max characters per line (10–120, default 42)
79
+ # @param max_lines_per_cue [Integer, nil] max lines per cue (1–4, default 2)
80
+ # @param chars_per_sec [Float, nil] reading speed when no duration is given (may be fractional, e.g. 15.5)
81
+ # @param gap_ms [Integer, nil] gap between cues (ms, default 80)
82
+ def subtitles(text, format: 'srt', duration_sec: nil, max_chars_per_line: nil,
83
+ max_lines_per_cue: nil, chars_per_sec: nil, gap_ms: nil)
84
+ @http.data('POST', '/subtitles', body: {
85
+ 'text' => text, 'format' => format, 'durationSec' => duration_sec,
86
+ 'maxCharsPerLine' => max_chars_per_line, 'maxLinesPerCue' => max_lines_per_cue,
87
+ 'charsPerSec' => chars_per_sec, 'gapMs' => gap_ms,
88
+ })
89
+ end
90
+
91
+ # Promo video render (F4) — remote image (+optional audio/subtitle) → branded video (ffmpeg) → R2 URL.
92
+ # @param image_url [String] publicly-accessible https image URL
93
+ # @param format [String] video ratio: '9:16' | '1:1' | '16:9'
94
+ # @param audio_url [String, nil] voiceover (mp3) URL; if given, the video duration is matched to the audio
95
+ # @param subtitle_text [String, nil] subtitle text to burn into the video
96
+ # @param subtitle_rtl [Boolean, nil] whether the subtitle is right-to-left (Arabic, etc.)
97
+ # HEAVY operation (write permission + low rate limit). Media is downloaded SSRF-protected (https + type + ≤25MB).
98
+ def videos(image_url, format, audio_url: nil, subtitle_text: nil, subtitle_rtl: nil,
99
+ bumper_duration_sec: nil, main_duration_sec: nil)
100
+ @http.data('POST', '/videos', body: {
101
+ 'imageUrl' => image_url, 'format' => format, 'audioUrl' => audio_url,
102
+ 'subtitleText' => subtitle_text, 'subtitleRtl' => subtitle_rtl,
103
+ 'bumperDurationSec' => bumper_duration_sec, 'mainDurationSec' => main_duration_sec,
104
+ })
105
+ end
106
+
107
+ def analytics
108
+ @http.data('GET', '/analytics')
109
+ end
110
+
111
+ # Post analytics list — source: 'external' is synced from the platform, not PostHubify (B5).
112
+ def analytics_posts(platform: nil, account_id: nil, source: nil, limit: nil, cursor: nil)
113
+ @http.req('GET', '/analytics/posts', query: {
114
+ 'platform' => platform, 'accountId' => account_id, 'source' => source,
115
+ 'limit' => limit, 'cursor' => cursor
116
+ })
117
+ end
118
+
119
+ # Stored daily metric time series (account/post/inbox aggregates).
120
+ def analytics_timeseries(days: nil, platform: nil, account_id: nil, from_date: nil, to_date: nil, source: nil)
121
+ @http.data('GET', '/analytics/timeseries', query: {
122
+ 'days' => days, 'platform' => platform, 'accountId' => account_id,
123
+ 'fromDate' => from_date, 'toDate' => to_date, 'source' => source
124
+ })
125
+ end
126
+
127
+ def usage
128
+ @http.data('GET', '/usage')
129
+ end
130
+
131
+ def logs(limit: nil, category: nil, status: nil, platform: nil, days: nil)
132
+ @http.data('GET', '/logs', query: {
133
+ 'limit' => limit, 'category' => category, 'status' => status,
134
+ 'platform' => platform, 'days' => days
135
+ })
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthubify
4
+ # Raised when an API request returns a non-2xx status.
5
+ #
6
+ # begin
7
+ # ph.posts.get("missing")
8
+ # rescue Posthubify::Error => e
9
+ # puts [e.status, e.code, e.message].inspect
10
+ # end
11
+ class Error < StandardError
12
+ attr_reader :status, :code, :body
13
+
14
+ def initialize(status, message, code = nil, body = nil)
15
+ super(message)
16
+ @status = status
17
+ @code = code
18
+ @body = body
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ module Posthubify
9
+ # HTTP transport layer — standard library only (net/http). Bearer auth, JSON body,
10
+ # multipart upload, `{ "data" => ... }` envelope unwrapping, non-2xx → Posthubify::Error.
11
+ class Transport
12
+ DEFAULT_BASE = 'http://localhost:8787/v1'
13
+ USER_AGENT = 'posthubify-ruby/0.1.0'
14
+
15
+ def initialize(api_key:, base_url: DEFAULT_BASE, timeout: 30)
16
+ raise ArgumentError, 'api_key required (sk_…)' if api_key.nil? || api_key.empty?
17
+
18
+ @key = api_key
19
+ @base = base_url.sub(%r{/\z}, '')
20
+ @timeout = timeout
21
+ end
22
+
23
+ # Raw request: 2xx → parsed body, otherwise Posthubify::Error.
24
+ # files: [field_name, bytes, file_name] — multipart upload (media.upload).
25
+ def req(method, path, query: nil, body: nil, files: nil, idempotency_key: nil)
26
+ uri = URI("#{@base}#{path}")
27
+ unless query.nil?
28
+ pairs = query.reject { |_k, v| v.nil? }
29
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
30
+ end
31
+
32
+ request = build_request(method, uri, body, files, idempotency_key)
33
+ http = Net::HTTP.new(uri.host, uri.port)
34
+ http.use_ssl = uri.scheme == 'https'
35
+ http.read_timeout = @timeout
36
+ http.open_timeout = @timeout
37
+
38
+ response = http.request(request)
39
+ parsed = parse(response.body)
40
+ status = response.code.to_i
41
+ unless (200..299).cover?(status)
42
+ message = (parsed.is_a?(Hash) && parsed['error']) || "HTTP #{status}"
43
+ code = parsed.is_a?(Hash) ? parsed['code'] : nil
44
+ raise Posthubify::Error.new(status, message, code, parsed)
45
+ end
46
+ parsed
47
+ rescue SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
48
+ raise Posthubify::Error.new(0, "Network error: #{e.message}")
49
+ end
50
+
51
+ # Unwraps the `{ "data" => ... }` envelope.
52
+ def data(method, path, query: nil, body: nil, files: nil, idempotency_key: nil)
53
+ result = req(method, path, query: query, body: body, files: files, idempotency_key: idempotency_key)
54
+ result.is_a?(Hash) && result.key?('data') ? result['data'] : result
55
+ end
56
+
57
+ private
58
+
59
+ def build_request(method, uri, body, files, idempotency_key)
60
+ klass = {
61
+ 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post, 'PUT' => Net::HTTP::Put,
62
+ 'PATCH' => Net::HTTP::Patch, 'DELETE' => Net::HTTP::Delete
63
+ }.fetch(method.to_s.upcase)
64
+ request = klass.new(uri)
65
+ request['Authorization'] = "Bearer #{@key}"
66
+ request['User-Agent'] = USER_AGENT
67
+ request['Accept'] = 'application/json'
68
+ request['Idempotency-Key'] = idempotency_key if idempotency_key
69
+
70
+ if files
71
+ field, content, filename = files
72
+ boundary = SecureRandom.hex(16)
73
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
74
+ request.body = multipart_body(field, content, filename, boundary)
75
+ elsif !body.nil?
76
+ request['Content-Type'] = 'application/json'
77
+ request.body = JSON.generate(body)
78
+ end
79
+ request
80
+ end
81
+
82
+ def multipart_body(field, content, filename, boundary)
83
+ mime = case File.extname(filename).downcase
84
+ when '.png' then 'image/png'
85
+ when '.jpg', '.jpeg' then 'image/jpeg'
86
+ when '.mp4' then 'video/mp4'
87
+ else 'application/octet-stream'
88
+ end
89
+ "--#{boundary}\r\n" \
90
+ "Content-Disposition: form-data; name=\"#{field}\"; filename=\"#{filename}\"\r\n" \
91
+ "Content-Type: #{mime}\r\n\r\n" + content + "\r\n--#{boundary}--\r\n"
92
+ end
93
+
94
+ def parse(raw)
95
+ return nil if raw.nil? || raw.empty?
96
+
97
+ JSON.parse(raw)
98
+ rescue JSON::ParserError
99
+ nil
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthubify
4
+ # Profiles (Node sdk .profiles) — workspace/brand separation.
5
+ class ProfilesResource
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ # List all profiles.
11
+ def list
12
+ @http.data('GET', '/profiles')
13
+ end
14
+
15
+ # Get a single profile.
16
+ def get(id)
17
+ @http.data('GET', "/profiles/#{id}")
18
+ end
19
+
20
+ # Create a profile (input: { name }).
21
+ def create(input)
22
+ @http.data('POST', '/profiles', body: input)
23
+ end
24
+
25
+ # Update a profile (input: name/description/color/isDefault).
26
+ def update(id, input)
27
+ @http.data('PATCH', "/profiles/#{id}", body: input)
28
+ end
29
+
30
+ # Delete a profile.
31
+ def delete(id)
32
+ @http.data('DELETE', "/profiles/#{id}")
33
+ end
34
+ end
35
+
36
+ # Connected platform accounts (Node sdk .accounts) — health, follower stats, OAuth linking.
37
+ class AccountsResource
38
+ def initialize(http)
39
+ @http = http
40
+ end
41
+
42
+ # List accounts (filter: profile_id/platform/is_active).
43
+ def list(profile_id: nil, platform: nil, is_active: nil)
44
+ @http.data('GET', '/accounts', query: {
45
+ 'profileId' => profile_id,
46
+ 'platform' => platform,
47
+ 'isActive' => is_active
48
+ })
49
+ end
50
+
51
+ # Update an account (input: label/profileId/isActive).
52
+ def update(id, input)
53
+ @http.data('PATCH', "/accounts/#{id}", body: input)
54
+ end
55
+
56
+ # Health of all accounts + summary (token expiry, reconnection needs).
57
+ def health
58
+ @http.data('GET', '/accounts/health')
59
+ end
60
+
61
+ # Health report for a single account.
62
+ def health_of(id)
63
+ @http.data('GET', "/accounts/#{id}/health")
64
+ end
65
+
66
+ # Pinterest boards (for posts.create options.board). 400 on an unsupported platform.
67
+ def boards(id)
68
+ @http.data('GET', "/accounts/#{id}/boards")
69
+ end
70
+
71
+ # Reddit subreddit flairs (for posts.create options.flairId). 400 on an unsupported platform.
72
+ def flairs(id)
73
+ @http.data('GET', "/accounts/#{id}/flairs")
74
+ end
75
+
76
+ # ── Entity pickers + publishing defaults (D8) ──
77
+ def reddit_subreddits(id)
78
+ @http.data('GET', "/accounts/#{id}/reddit-subreddits")
79
+ end
80
+
81
+ def set_default_subreddit(id, subreddit)
82
+ @http.data('PUT', "/accounts/#{id}/reddit-subreddits", body: { 'subreddit' => subreddit })
83
+ end
84
+
85
+ def youtube_playlists(id)
86
+ @http.data('GET', "/accounts/#{id}/youtube-playlists")
87
+ end
88
+
89
+ def set_default_playlist(id, playlist_id)
90
+ @http.data('PUT', "/accounts/#{id}/youtube-playlists", body: { 'playlistId' => playlist_id })
91
+ end
92
+
93
+ def linkedin_organizations(id)
94
+ @http.data('GET', "/accounts/#{id}/linkedin-organizations")
95
+ end
96
+
97
+ def pinterest_boards(id)
98
+ @http.data('GET', "/accounts/#{id}/pinterest-boards")
99
+ end
100
+
101
+ def set_default_board(id, board_id)
102
+ @http.data('PUT', "/accounts/#{id}/pinterest-boards", body: { 'boardId' => board_id })
103
+ end
104
+
105
+ # ── Bot command menu (A1) — Telegram bot "/" commands (per account). ──
106
+ # Get the bot command menu (Telegram getMyCommands). scope/language_code optional.
107
+ def bot_commands(id, scope: nil, language_code: nil)
108
+ @http.data('GET', "/accounts/#{id}/bot-commands", query: {
109
+ 'scope' => scope,
110
+ 'languageCode' => language_code
111
+ })
112
+ end
113
+
114
+ # Set the bot command menu (Telegram setMyCommands).
115
+ def set_bot_commands(id, commands, scope: nil, language_code: nil)
116
+ @http.data('PUT', "/accounts/#{id}/bot-commands", body: {
117
+ 'commands' => commands,
118
+ 'scope' => scope,
119
+ 'languageCode' => language_code
120
+ })
121
+ end
122
+
123
+ # Clear the bot command menu (Telegram deleteMyCommands).
124
+ def delete_bot_commands(id, scope: nil, language_code: nil)
125
+ @http.data('DELETE', "/accounts/#{id}/bot-commands", query: {
126
+ 'scope' => scope,
127
+ 'languageCode' => language_code
128
+ })
129
+ end
130
+
131
+ # Follower history + growth across all accounts (granularity: daily|weekly|monthly).
132
+ def follower_stats_all(days: nil, granularity: nil)
133
+ @http.data('GET', '/accounts/follower-stats', query: {
134
+ 'days' => days,
135
+ 'granularity' => granularity
136
+ })
137
+ end
138
+
139
+ # Follower history + change for a single account (granularity: daily|weekly|monthly).
140
+ def follower_stats(id, days: nil, granularity: nil)
141
+ @http.data('GET', "/accounts/#{id}/follower-stats", query: {
142
+ 'days' => days,
143
+ 'granularity' => granularity
144
+ })
145
+ end
146
+
147
+ # Delete an account.
148
+ def delete(id)
149
+ @http.data('DELETE', "/accounts/#{id}")
150
+ end
151
+
152
+ # OAuth connect URL — the user visits it in a browser (query: profile_id).
153
+ def connect_url(platform, profile_id: nil)
154
+ @http.data('GET', "/connect/#{platform}", query: { 'profileId' => profile_id })
155
+ end
156
+
157
+ # ── Headless credential connect (D8 cp2) — linking without OAuth. ──
158
+ def connect_bluesky(handle, app_password, profile_id: nil, label: nil)
159
+ @http.data('POST', '/connect/bluesky/credentials', body: { 'handle' => handle, 'appPassword' => app_password, 'profileId' => profile_id, 'label' => label })
160
+ end
161
+
162
+ def connect_telegram(bot_token, chat_id, profile_id: nil, label: nil)
163
+ @http.data('POST', '/connect/telegram', body: { 'botToken' => bot_token, 'chatId' => chat_id, 'profileId' => profile_id, 'label' => label })
164
+ end
165
+
166
+ def connect_whatsapp(access_token, phone_number_id, recipients, waba_id: nil, profile_id: nil, label: nil)
167
+ @http.data('POST', '/connect/whatsapp/credentials', body: { 'accessToken' => access_token, 'phoneNumberId' => phone_number_id, 'recipients' => recipients, 'wabaId' => waba_id, 'profileId' => profile_id, 'label' => label })
168
+ end
169
+
170
+ def connect_line(channel_access_token, channel_secret, profile_id: nil, label: nil)
171
+ @http.data('POST', '/connect/line/credentials', body: { 'channelAccessToken' => channel_access_token, 'channelSecret' => channel_secret, 'profileId' => profile_id, 'label' => label })
172
+ end
173
+
174
+ # ── Headless OAuth connect (D8 cp3) — tempToken start/status. ──
175
+ # Start headless OAuth → {tempToken, authUrl}: open authUrl in a browser, then poll status.
176
+ def connect_oauth_start(platform, profile_id: nil, label: nil)
177
+ @http.data('POST', '/connect/oauth/start', body: { 'platform' => platform, 'profileId' => profile_id, 'label' => label })
178
+ end
179
+
180
+ # Headless OAuth status (poll): pending | connected (+account) | error.
181
+ def connect_oauth_status(temp_token)
182
+ @http.data('GET', '/connect/oauth/status', query: { 'tempToken' => temp_token })
183
+ end
184
+ end
185
+ end