jebin2 commited on
Commit
693a2b5
Β·
1 Parent(s): 4930013

feat: Integrate OneUp API for social media publishing

Browse files

- Implementation of OneUpClient for API interactions
- Refactor publisher.py to use OneUp for scheduling/publishing
- Update GCS utils to support signed URL generation for OneUp
- Add OneUp API specification file

social_media_publishers/oneup_api_spec.txt ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Authentication
2
+ Overview
3
+ To interact with the OneUp API, you’ll need to authenticate using your personal API key. This key identifies your account and must be included in every API request.
4
+
5
+ Step 1: Generate Your API Key
6
+ Go to https://www.oneupapp.io/api-access.
7
+ Log in to your OneUp account.
8
+ Click Generate API Key.
9
+ Copy your generated API key β€” you’ll use it in your API requests.
10
+ Step 2: Use Your API Key in Requests
11
+ Include your API key as a query parameter named apiKey in all your API calls.
12
+
13
+ Example Request:
14
+
15
+ GET https://www.oneupapp.io/api/listcategory?apiKey=YOUR_API_KEY
16
+
17
+ Example Response:
18
+
19
+ {
20
+ "message": "OK",
21
+ "error": false,
22
+ "data": [
23
+ {
24
+ "id": 49839,
25
+ "category_name": "AAA testingg",
26
+ "isPaused": 0,
27
+ "created_at": "2020-12-09 14:37:45"
28
+ }
29
+ ]
30
+ }
31
+
32
+ βœ… Result: The API authenticates your key and returns data linked to your account.
33
+
34
+ List Categories
35
+ Overview
36
+ The List Categories endpoint allows you to retrieve all categories associated with your OneUp account. Each category is used to group and manage your connected social accounts, not posts. You can use these categories to identify which accounts belong together under the same group.
37
+
38
+ Base URL:
39
+
40
+ https://www.oneupapp.io
41
+
42
+ Endpoint
43
+ GET /api/listcategory?apiKey=YOUR_API_KEY
44
+
45
+ Example:
46
+
47
+ GET https://www.oneupapp.io/api/listcategory?apiKey=621544d93ffe2db52b01
48
+
49
+ Request Parameters
50
+ Parameter Required Description
51
+ apiKey Yes Your personal API key generated from the API Access page.
52
+ Sample Request
53
+ curl -X GET "https://www.oneupapp.io/api/listcategory?apiKey=YOUR_API_KEY"
54
+
55
+ Sample Response
56
+ {
57
+ "message": "OK",
58
+ "error": false,
59
+ "data": [
60
+ {
61
+ "id": 49839,
62
+ "category_name": "AAA testingg",
63
+ "isPaused": 0,
64
+ "created_at": "2020-12-09 14:37:45"
65
+ },
66
+ {
67
+ "id": 63889,
68
+ "category_name": "Client #2",
69
+ "isPaused": 0,
70
+ "created_at": "2021-10-05 18:29:04"
71
+ }
72
+ ]
73
+ }
74
+
75
+ βœ… Result: The response returns all categories linked to your account. Each category includes an id, category_name, and creation details β€” allowing you to later fetch the accounts grouped under that category.
76
+
77
+ List Category Accounts
78
+ Overview
79
+ The List Category Accounts endpoint allows you to retrieve all social accounts associated with a specific category. Each category can have one or more connected accounts, such as Facebook Pages, Twitter profiles, Google Business and more.
80
+
81
+ Base URL:
82
+
83
+ https://www.oneupapp.io
84
+
85
+ Endpoint
86
+ GET /api/listcategoryaccount?apiKey=YOUR_API_KEY&category_id=CATEGORY_ID
87
+
88
+ Example:
89
+
90
+ GET https://www.oneupapp.io/api/listcategoryaccount?apiKey=621544d93ffe2db52b01&category_id=49839
91
+
92
+
93
+ Request Parameters
94
+ Parameter Required Description
95
+ apiKey Yes Your personal API key generated from the API Access page.
96
+ category_id Yes The unique ID of the category whose connected accounts you want to retrieve.
97
+ Sample Request
98
+ curl -X GET "https://www.oneupapp.io/api/listcategoryaccount?apiKey=YOUR_API_KEY&category_id=CATEGORY_ID"
99
+
100
+
101
+ Sample Response
102
+ {
103
+ "message": "OK",
104
+ "error": false,
105
+ "data": [
106
+ {
107
+ "category_id": 49839,
108
+ "social_network_name": "kumarvishi",
109
+ "social_network_id": "pin_kumarvishi",
110
+ "social_network_type": "Pinterest"
111
+ },
112
+ {
113
+ "category_id": 49839,
114
+ "social_network_name": "OneUp (United States)",
115
+ "social_network_id": "accounts/116185162672310389659/locations/1366069594757511498",
116
+ "social_network_type": "GBP"
117
+ }
118
+ ]
119
+ }
120
+
121
+ βœ… Result: The response lists all the social media accounts linked to the specified category. Each item includes:
122
+
123
+ social_network_name β€” The display name of the connected social account.
124
+ social_network_id β€” The unique identifier used when scheduling or publishing posts.
125
+ social_network_type β€” The type of network (e.g., Facebook, Pinterest, GBP, Twitter, etc.).
126
+ Usage Example
127
+ First, use the List Categories endpoint to get your desired category_id.
128
+ Then, use this endpoint to find all accounts linked to that category.
129
+ Finally, use the social_network_id values when creating or scheduling posts.
130
+
131
+ List Social Accounts
132
+ Overview
133
+ The List Social Accounts endpoint allows you to retrieve all social media accounts connected to your OneUp account. This endpoint lists every account, regardless of whether it belongs to a specific category or not.
134
+
135
+ Base URL:
136
+
137
+ https://www.oneupapp.io
138
+
139
+ Endpoint
140
+ GET /api/listsocialaccounts?apiKey=YOUR_API_KEY
141
+
142
+ Example:
143
+
144
+ GET https://www.oneupapp.io/api/listsocialaccounts?apiKey=621544d93ffe2db52b01
145
+
146
+ Request Parameters
147
+ Parameter Required Description
148
+ apiKey Yes Your personal API key generated from the API Access page.
149
+ Sample Request
150
+ curl -X GET "https://www.oneupapp.io/api/listsocialaccounts?apiKey=YOUR_API_KEY"
151
+
152
+ Sample Response
153
+ {
154
+ "message": "OK",
155
+ "error": false,
156
+ "data": [
157
+ {
158
+ "username": "SaaS_Growth_Guy",
159
+ "full_name": "SaaS growth tip guy",
160
+ "is_expired": 0,
161
+ "social_network_type": "X",
162
+ "need_refresh": false
163
+ },
164
+ {
165
+ "username": "Davis William",
166
+ "full_name": "Z Davis",
167
+ "is_expired": 0,
168
+ "social_network_type": "Facebook",
169
+ "need_refresh": false
170
+ }
171
+ ]
172
+ }
173
+
174
+ βœ… Result: The response returns all the social accounts linked to your OneUp account. Each object includes:
175
+
176
+ username β€” The handle or username of the account.
177
+ full_name β€” The display name associated with the account.
178
+ is_expired β€” Indicates whether the account connection has expired (0 = active, 1 = expired).
179
+ social_network_type β€” The type of social network (e.g., Facebook, X, Pinterest, GBP, etc.).
180
+ need_refresh β€” Indicates if the account requires reauthorization.
181
+
182
+ Usage Example
183
+ This endpoint is useful when you want to:
184
+
185
+ Retrieve a full list of all connected social media accounts.
186
+ Identify which accounts may need to be refreshed or reconnected.
187
+ Use the social_network_type and username to display connected networks in your app dashboard.
188
+
189
+ Create Text Post
190
+ Overview
191
+ The Create Text Post endpoint allows you to schedule and publish text-based posts to one or multiple connected social accounts under a specific category. You can set the post content, assign it to a category, and define the date and time for scheduling.
192
+
193
+ Base URL:
194
+
195
+ https://www.oneupapp.io
196
+
197
+ Endpoint
198
+ POST /api/scheduletextpost
199
+
200
+ Request Parameters
201
+ Parameter Required Description
202
+ apiKey Yes Your personal API key generated from the API Access page.
203
+ category_id Yes The ID of the category that groups your target social accounts.
204
+ social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
205
+ scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
206
+ title No The title of your post.
207
+ content Yes The text content of your post.
208
+ first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
209
+ Sample Request
210
+ curl --location --request POST \
211
+ "https://www.oneupapp.io/api/scheduletextpost" \
212
+ --data-urlencode "apiKey=621544d93ffe2db52b01" \
213
+ --data-urlencode "category_id=49839" \
214
+ --data-urlencode 'social_network_id=["pin_kumarvishi","accounts/116185162672310389659/locations/1366069594757511498"]' \
215
+ --data-urlencode "scheduled_date_time=2025-10-23 14:00" \
216
+ --data-urlencode "title=Hello World Title" \
217
+ --data-urlencode "content=Hello World! This is a scheduled post."
218
+
219
+
220
+ Sample Response
221
+ {
222
+ "message": "1 new Posts Scheduled.",
223
+ "error": false,
224
+ "data": []
225
+ }
226
+
227
+ βœ… Result: Your post has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
228
+
229
+ Platform Support & Limitations
230
+ Network Character Limit Notes
231
+ Facebook 10,000 Full text supported with formatting
232
+ X(Twitter) 280 25,000 for premium accounts
233
+ LinkedIn 3,000 Supports formatting like bold, italic
234
+ Instagram 2,200 For feed post captions
235
+ Google Business 1,500 For business updates
236
+ Pinterest 500 For pin descriptions
237
+ Threads 500 Maximum length
238
+ Bluesky 300 Maximum length
239
+ Tips & Notes
240
+ Make sure your target social accounts are active and not expired before scheduling.
241
+ The social_network_id must exactly match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
242
+ For immediate publishing, you can set the scheduled_date_time to the current timestamp.
243
+ Avoid overloading with too many simultaneous posts to prevent API rate limiting.
244
+
245
+ Create Image Post
246
+ Overview
247
+ The Create Image Post endpoint allows you to schedule and publish image-based posts to one or multiple connected social accounts under a specific category. You can set the post content, attach one or more images, assign it to a category, and define the date and time for scheduling.
248
+
249
+ Base URL:
250
+
251
+ https://www.oneupapp.io
252
+
253
+ Endpoint
254
+ POST /api/scheduleimagepost
255
+
256
+ Request Parameters
257
+ Parameter Required Description
258
+ apiKey Yes Your personal API key generated from the API Access page.
259
+ category_id Yes The ID of the category that groups your target social accounts.
260
+ social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
261
+ scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
262
+ title No The title of your post.
263
+ content Yes The text content of your post.
264
+ image_url Yes The URL(s) of the image(s). Separate multiple images with ~~.
265
+ first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
266
+ Platform-Specific Parameters
267
+ Some social networks support additional parameters for specific image post types:
268
+
269
+ Instagram:
270
+
271
+ instagram: A JSON object with the following option:
272
+ isStory (boolean): Set to true to publish as an Instagram Story. Default: false
273
+ Facebook:
274
+
275
+ facebook: A JSON object with the following option:
276
+ isStory (boolean): Set to true to publish as a Facebook Story. Default: false
277
+ Snapchat:
278
+
279
+ snapchat: A JSON object with the following option:
280
+ isSpotLight (boolean): Set to true to publish as a Snapchat Spotlight. Default: false
281
+ TikTok:
282
+
283
+ tiktok: A JSON object with the following option:
284
+ autoAddMusic (boolean): Set to true to automatically add music to the image post. Default: true
285
+ Example with platform-specific parameters:
286
+
287
+ curl --location --request POST \
288
+ "https://www.oneupapp.io/api/scheduleimagepost" \
289
+ --data-urlencode "apiKey=621544d93ffe2db52b01" \
290
+ --data-urlencode "category_id=49839" \
291
+ --data-urlencode 'social_network_id=["113024478527731"]' \
292
+ --data-urlencode "scheduled_date_time=2026-12-12 13:13" \
293
+ --data-urlencode "title=My Image Post" \
294
+ --data-urlencode "content=Image post" \
295
+ --data-urlencode "image_url=https://cdn.filestackcontent.com/BT933lwUSEKkmpfI9O57" \
296
+ --data-urlencode 'instagram={"isStory":true}'
297
+
298
+ Sample Response
299
+ {
300
+ "message": "1 new Posts Scheduled.",
301
+ "error": false,
302
+ "data": []
303
+ }
304
+
305
+ βœ… Result: Your image post (with one or multiple images) has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
306
+
307
+ Platform Photo Upload Limits
308
+ Platform Max Photos Photo Size Limit Caption Support Notes
309
+ Facebook 20 No restriction Yes Supports alt text, no restrictions on photo size.
310
+ Instagram 10 8 MB Yes Minimum resolution: 320Γ—320 px, automatically cropped.
311
+ TikTok 35 No restriction No Cannot mix photos with GIFs or videos.
312
+ Threads 20 8 MB No Cannot mix photos with GIFs or videos.
313
+ X(Twitter) 4 5 MB No Supports only up to 4 photos per post.
314
+ LinkedIn 9 No restriction No Photos and GIFs are supported, cannot mix with videos.
315
+ Bluesky 4 1 MB No Supports up to 4 images per post, alt text available.
316
+ Tips & Notes
317
+ Make sure your target social accounts are active and not expired before scheduling.
318
+ The social_network_id must match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
319
+ Ensure all image_urls are publicly accessible and point directly to image files.
320
+ Use ~~ to separate multiple images in the image_url parameter.
321
+ Avoid overloading with too many simultaneous posts to prevent API rate limiting.
322
+ When using platform-specific parameters, ensure the JSON is properly formatted and URL-encoded in the request.
323
+
324
+ Create Video Post
325
+ Overview
326
+ The Create Video Post endpoint allows you to schedule and publish video-based posts to one or multiple connected social accounts under a specific category. You can set the post content, attach a video URL, assign it to a category, and define the date and time for scheduling.
327
+
328
+ Base URL:
329
+
330
+ https://www.oneupapp.io
331
+
332
+ Endpoint
333
+ POST /api/schedulevideopost
334
+
335
+ Request Parameters
336
+ Parameter Required Description
337
+ apiKey Yes Your personal API key generated from the API Access page.
338
+ category_id Yes The ID of the category that groups your target social accounts.
339
+ social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
340
+ scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
341
+ title No The title of your post.
342
+ content Yes The text content of your post.
343
+ video_url Yes The URL to the video you want to attach to the post.
344
+ thumbnail_url No The URL of the thumbnail image for video posts (optional and only for video posts).
345
+ first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
346
+ Platform-Specific Parameters
347
+ Some social networks support additional parameters for specific video post types:
348
+
349
+ Instagram:
350
+
351
+ instagram: A JSON object with the following option:
352
+ isStory (boolean): Set to true to publish as an Instagram Story. Default: false
353
+ Facebook:
354
+
355
+ facebook: A JSON object with the following option:
356
+ isStory (boolean): Set to true to publish as a Facebook Story. Default: false
357
+ Snapchat:
358
+
359
+ snapchat: A JSON object with the following option:
360
+ isSpotLight (boolean): Set to true to publish as a Snapchat Spotlight. Default: false
361
+ Example with platform-specific parameters:
362
+
363
+ curl --location --request POST \
364
+ "https://www.oneupapp.io/api/schedulevideopost" \
365
+ --data-urlencode "apiKey=621544d93ffe2db52b01" \
366
+ --data-urlencode "category_id=49839" \
367
+ --data-urlencode 'social_network_id=["17841408823790514"]' \
368
+ --data-urlencode "scheduled_date_time=2026-12-12 13:13" \
369
+ --data-urlencode "title=My Video Post" \
370
+ --data-urlencode "content=Video post from API" \
371
+ --data-urlencode "video_url=https://cdn.filestackcontent.com/tpVlPE0qT7u4TwyPoA1M" \
372
+ --data-urlencode 'instagram={"isStory":true}'
373
+
374
+ Sample Response
375
+ {
376
+ "message": "1 new Posts Scheduled.",
377
+ "error": false,
378
+ "data": []
379
+ }
380
+
381
+ βœ… Result: Your video post has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
382
+
383
+ Tips & Notes
384
+ Ensure your target social accounts are active and not expired before scheduling.
385
+ The social_network_id must exactly match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
386
+ The video_url must be publicly accessible and point directly to a video file.
387
+ For immediate publishing, set scheduled_date_time to the current timestamp.
388
+ Avoid overloading with too many simultaneous posts to prevent API rate limiting.
389
+ When using platform-specific parameters, ensure the JSON is properly formatted and URL-encoded in the request.
social_media_publishers/oneup_client.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from typing import Dict, Any, List, Optional
3
+ from src.utils import logger
4
+ from src.config import get_config_value
5
+
6
+ class OneUpClient:
7
+ """
8
+ Client for interacting with the OneUp API.
9
+ Docs: https://www.oneupapp.io/api-doc
10
+ """
11
+ BASE_URL = "https://www.oneupapp.io/api"
12
+
13
+ def __init__(self, api_key: str = None):
14
+ self.api_key = api_key or get_config_value("ONEUP_API_KEY")
15
+ if not self.api_key:
16
+ raise ValueError("ONEUP_API_KEY is required but not found in config/env")
17
+
18
+ def _get_headers(self) -> Dict[str, str]:
19
+ """Header not typically used for auth in OneUp (query param), but good practice."""
20
+ return {}
21
+
22
+ def _make_request(self, method: str, endpoint: str, params: Dict[str, Any] = None, data: Dict[str, Any] = None) -> Dict[str, Any]:
23
+ """Common request handler with error checking."""
24
+ url = f"{self.BASE_URL}{endpoint}"
25
+ if params is None:
26
+ params = {}
27
+
28
+ # Always inject apiKey
29
+ params["apiKey"] = self.api_key
30
+
31
+ try:
32
+ response = requests.request(method, url, params=params, data=data)
33
+ # Note: OneUp API often uses query params even for POSTs according to docs,
34
+ # or x-www-form-urlencoded. Docs say: --data-urlencode
35
+ # requests 'data' arg sends form-encoded by default.
36
+
37
+ response.raise_for_status()
38
+ result = response.json()
39
+
40
+ if result.get("error"):
41
+ raise Exception(f"OneUp API Error: {result.get('message', 'Unknown error')}")
42
+
43
+ return result
44
+ except requests.exceptions.RequestException as e:
45
+ logger.error(f"❌ OneUp API Request Failed ({endpoint}): {e}")
46
+ if e.response:
47
+ logger.error(f"Response: {e.response.text}")
48
+ raise
49
+
50
+ def get_categories(self) -> List[Dict[str, Any]]:
51
+ """List all categories."""
52
+ response = self._make_request("GET", "/listcategory")
53
+ return response.get("data", [])
54
+
55
+ def get_accounts_in_category(self, category_id: int) -> List[Dict[str, Any]]:
56
+ """List accounts in a specific category."""
57
+ params = {"category_id": category_id}
58
+ response = self._make_request("GET", "/listcategoryaccount", params=params)
59
+ return response.get("data", [])
60
+
61
+ def schedule_video_post(
62
+ self,
63
+ category_id: int,
64
+ social_network_ids: List[str],
65
+ video_url: str,
66
+ title: str = "",
67
+ content: str = "",
68
+ scheduled_date_time: str = None, # YYYY-MM-DD HH:MM
69
+ publish_now: bool = False
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Schedule a video post.
73
+
74
+ Args:
75
+ category_id: Target category ID
76
+ social_network_ids: List of account IDs or ["ALL"]
77
+ video_url: Public URL of the video
78
+ title: Post title
79
+ content: Post text/caption
80
+ scheduled_date_time: "YYYY-MM-DD HH:MM"
81
+ publish_now: If True, uses current time to publish immediately (overrides scheduled_date_time)
82
+ """
83
+ from datetime import datetime
84
+
85
+ if publish_now:
86
+ scheduled_date_time = datetime.now().strftime("%Y-%m-%d %H:%M")
87
+ elif not scheduled_date_time:
88
+ raise ValueError("Must provide either scheduled_date_time or publish_now=True")
89
+
90
+ import json
91
+
92
+ # OneUp expects social_network_id as a JSON array string
93
+ # e.g. '["all"]' or '["id1", "id2"]'
94
+ # If the user passes ["ALL"] or ["all"], we respect that to post to all accounts in category
95
+ social_network_id_str = json.dumps(social_network_ids)
96
+
97
+ data = {
98
+ "category_id": category_id,
99
+ "social_network_id": social_network_id_str, # This needs to be passed in body/params
100
+ "scheduled_date_time": scheduled_date_time,
101
+ "title": title,
102
+ "content": content,
103
+ "video_url": video_url
104
+ }
105
+
106
+ # NOTE: The docs example shows data-urlencode for everything including apiKey.
107
+ # But we pass apiKey in query params via _make_request helper.
108
+ # We'll pass the rest in 'data' which requests handles as form-encoded.
109
+
110
+ return self._make_request("POST", "/schedulevideopost", data=data)
111
+
112
+ if __name__ == "__main__":
113
+ # Test block
114
+ try:
115
+ client = OneUpClient()
116
+ cats = client.get_categories()
117
+ print(f"Categories: {len(cats)}")
118
+ for c in cats:
119
+ print(f"- {c['id']}: {c['category_name']}")
120
+ accounts = client.get_accounts_in_category(c['id'])
121
+ print(f" Accounts: {len(accounts)}")
122
+ for a in accounts:
123
+ print(f" - {a.get('social_network_name')} ({a.get('social_network_type')})")
124
+ except Exception as e:
125
+ print(f"Test failed: {e}")
social_media_publishers/publisher.py CHANGED
@@ -1,8 +1,7 @@
1
  """
2
  publisher.py
3
- Unified social media publisher for all platforms.
4
- Lists videos from GCS, publishes to all platforms, logs to Google Sheets.
5
- Runs 3x daily via GitHub Actions.
6
  """
7
 
8
  import sys
@@ -18,38 +17,12 @@ from src.config import get_config_value
18
  from src.google_src.gcs_utils import list_gcs_files, find_and_download_gcs_file, upload_file_to_gcs
19
  from src.google_src.google_sheet import GoogleSheetReader
20
  from src.utils import logger
21
-
22
- # Initialize configuration
23
- # Config is lazily loaded via ConfigProxy
24
-
25
- # Available platforms to publish to (can be filtered via env var)
26
- ALL_PLATFORMS = ["youtube", "tiktok", "instagram", "facebook"]
27
-
28
- def get_active_platforms() -> List[str]:
29
- """Get list of active platforms from env var or default to all."""
30
- platforms_env = get_config_value("SOCIAL_MEDIA_PUBLISH_PLATFORMS")
31
- if platforms_env:
32
- requested = [p.strip().lower() for p in platforms_env.split(",") if p.strip()]
33
- return [p for p in requested if p in ALL_PLATFORMS]
34
- return ALL_PLATFORMS
35
-
36
- PLATFORMS = get_active_platforms()
37
 
38
  # GSheet columns for publisher logs
39
  LOG_HEADER = ["Timestamp", "Platform", "Published File", "Published URL", "Account", "Status", "Error"]
40
 
41
 
42
- def get_upload_limit() -> Optional[int]:
43
- """Get upload limit from env var. Returns None if no limit set."""
44
- limit = get_config_value("SOCIAL_MEDIA_PUBLISHER_UPLOAD_LIMIT")
45
- if limit:
46
- try:
47
- return int(limit)
48
- except ValueError:
49
- return None
50
- return None
51
-
52
-
53
  def get_publisher_logs_worksheet() -> GoogleSheetReader:
54
  """Get GoogleSheetReader for publisher logs worksheet."""
55
  worksheet_name = get_config_value("publisher_logs_worksheet")
@@ -77,157 +50,96 @@ def load_published_files() -> set:
77
 
78
  def log_publish_result(
79
  gcs_filename: str,
80
- platform: str,
81
  result: Dict[str, Any],
82
- account: str = ""
83
  ):
84
  """Log publish result to Google Sheets."""
85
  try:
86
  reader = get_publisher_logs_worksheet()
87
 
88
- status = "success" if result.get("success") else "error"
89
- published_url = result.get("url", result.get("video_url", ""))
90
- error_msg = result.get("error", "")
 
 
 
 
 
 
 
91
 
92
  reader.create_or_update_sheet(
93
  header=LOG_HEADER,
94
  values=[{
95
  "Timestamp": datetime.now().isoformat(),
96
- "Platform": platform,
97
  "Published File": gcs_filename,
98
  "Published URL": published_url,
99
- "Account": account,
100
  "Status": status,
101
  "Error": error_msg
102
  }]
103
  )
104
- logger.info(f"βœ“ Logged {platform} publish result to GSheet")
105
  except Exception as e:
106
  logger.error(f"❌ Failed to log to GSheet: {e}")
107
 
108
 
109
- def get_connected_accounts(platform: str) -> List[Dict[str, Any]]:
110
- """Get list of connected accounts for a platform."""
111
- try:
112
- from social_media_publishers.factory import PublisherFactory
113
- creator = PublisherFactory.get_auth_creator(platform)
114
- return creator.list_connected_accounts()
115
- except Exception as e:
116
- logger.warning(f"⚠️ Could not list {platform} accounts: {e}")
117
- return []
118
-
119
-
120
- def get_last_used_account(platform: str) -> Optional[str]:
121
- """Get the last used account for a platform from GSheet logs."""
122
- try:
123
- reader = get_publisher_logs_worksheet()
124
- df = reader.get_dataframe()
125
-
126
- if "Platform" in df.columns and "Account" in df.columns:
127
- # Filter by platform and successful publishes
128
- platform_df = df[df["Platform"] == platform]
129
- if "Status" in df.columns:
130
- platform_df = platform_df[platform_df["Status"] == "success"]
131
-
132
- if not platform_df.empty:
133
- # Get the most recent account used
134
- last_account = platform_df.iloc[-1]["Account"]
135
- return last_account if last_account else None
136
- return None
137
- except Exception as e:
138
- logger.warning(f"⚠️ Could not get last used account for {platform}: {e}")
139
- return None
140
-
141
-
142
- def get_next_account_round_robin(platform: str, accounts: List[Dict[str, Any]]) -> Dict[str, Any]:
143
  """
144
- Get the next account in round-robin order for a platform.
145
- Tracks last used account from GSheet and rotates to the next one.
 
 
 
 
 
 
 
 
146
  """
147
- if not accounts:
148
- return None
149
-
150
- if len(accounts) == 1:
151
- return accounts[0]
152
-
153
- # Get last used account
154
- last_used = get_last_used_account(platform)
155
-
156
- if not last_used:
157
- # No previous publish, use first account
158
- logger.info(f"πŸ”„ {platform}: No previous publish, using first account")
159
- return accounts[0]
160
-
161
- # Find the index of the last used account
162
- account_names = [acc.get("name", "") for acc in accounts]
163
 
 
164
  try:
165
- last_index = account_names.index(last_used)
166
- # Get next account (wrap around)
167
- next_index = (last_index + 1) % len(accounts)
168
- logger.info(f"πŸ”„ {platform}: Round-robin from '{last_used}' to '{account_names[next_index]}'")
169
- return accounts[next_index]
170
- except ValueError:
171
- # Last used account not found in current list, use first
172
- logger.info(f"πŸ”„ {platform}: Last account not found, using first account")
173
- return accounts[0]
174
-
175
 
176
- def publish_to_platform(
177
- platform: str,
178
- video_path: str,
179
- gcs_filename: str,
180
- account_id: str,
181
- caption: str = ""
182
- ) -> Dict[str, Any]:
183
- """Publish video to a specific platform."""
184
  try:
185
- from social_media_publishers.factory import PublisherFactory
 
 
 
186
 
187
- publisher = PublisherFactory.get_publisher(platform)
188
- publisher.authenticate(account_id=account_id)
 
 
189
 
190
- # Instagram needs public URL, not local file
191
- if platform == "instagram":
192
- uploaded = upload_file_to_gcs(
193
- video_path,
194
- account_name="final_data",
195
- generate_signed_url=False,
196
- fallback_to_drive=False,
197
- save_in_drive_also=False
198
- )
199
- content_path = uploaded.get("public_url", video_path)
200
- else:
201
- content_path = video_path
202
-
203
- # Prepare metadata
204
- metadata = {
205
- "title": caption or "Check this out! πŸ”₯",
206
- "description": caption or "#shorts #viral",
207
- "caption": caption or "Check this out! πŸ”₯",
208
- "privacy_status": "public" if get_config_value('video_publish_public') else "private",
209
- "privacy_level": "PUBLIC_TO_EVERYONE" if get_config_value('video_publish_public') else "SELF_ONLY", # TikTok
210
- }
211
-
212
- result = publisher.publish(content_path, metadata)
213
- return result
214
 
 
 
 
215
  except Exception as e:
216
- logger.error(f"❌ {platform} publish failed: {e}")
217
- return {"error": str(e)}
218
 
 
219
 
220
- def run_publisher():
221
- """
222
- Main publisher workflow:
223
- 1. List videos from GCS
224
- 2. Check which are already published (from GSheet)
225
- 3. Publish unpublished videos to all platforms
226
- 4. Log results to GSheet
227
- """
228
- logger.info("πŸš€ Starting unified publisher...")
229
-
230
- # Get all videos from GCS
231
  gcs_files = list_gcs_files()
232
  logger.info(f"πŸ“ Found {len(gcs_files)} videos in GCS")
233
 
@@ -235,41 +147,15 @@ def run_publisher():
235
  logger.info("βœ… No videos to publish")
236
  return
237
 
238
- # Load already published files
239
  published = load_published_files()
240
-
241
- # Filter to unpublished videos
242
  unpublished = [f for f in gcs_files if f not in published]
243
  logger.info(f"πŸ†• {len(unpublished)} unpublished videos to process")
244
 
245
  if not unpublished:
246
  logger.info("βœ… All videos already published")
247
  return
248
-
249
- # Get connected accounts for each platform (with round-robin selection)
250
- platform_accounts = {}
251
- all_platform_accounts = {} # Store all accounts for round-robin
252
- upload_limit = get_upload_limit()
253
- platform_upload_counts = {} # Track uploads per platform (int)
254
-
255
- for platform in PLATFORMS:
256
- accounts = get_connected_accounts(platform)
257
- if accounts:
258
- all_platform_accounts[platform] = accounts
259
- # Select next account using round-robin
260
- next_account = get_next_account_round_robin(platform, accounts)
261
- platform_accounts[platform] = next_account
262
- platform_upload_counts[platform] = 0
263
- limit_str = f"limit: {upload_limit}" if upload_limit is not None else "no limit"
264
- logger.info(f"βœ“ {platform}: {next_account.get('name', 'connected')} ({limit_str})")
265
- else:
266
- logger.warning(f"⚠️ No connected account for {platform}")
267
-
268
- if not platform_accounts:
269
- logger.error("❌ No connected accounts found for any platform")
270
- return
271
-
272
- # Process each unpublished video
273
  success_count = 0
274
 
275
  for idx, gcs_filename in enumerate(unpublished):
@@ -277,120 +163,80 @@ def run_publisher():
277
  logger.info(f"πŸ“Ί [{idx+1}/{len(unpublished)}] Processing: {gcs_filename}")
278
  logger.info(f"{'='*50}")
279
 
280
-
281
-
282
- # Download video from GCS
283
- local_path = find_and_download_gcs_file(gcs_filename)
284
- if not local_path or not os.path.exists(local_path):
285
- logger.error(f"❌ Failed to download: {gcs_filename}")
286
- continue
287
-
288
- # Generate caption from filename
289
- basename = os.path.basename(gcs_filename)
290
- caption = os.path.splitext(basename)[0].replace("_", " ")[:100]
291
-
292
- # Publish to each platform
293
- video_success = False
294
- for platform, account_info in list(platform_accounts.items()):
295
- # Check upload limit
296
- if upload_limit is not None and platform_upload_counts.get(platform, 0) >= upload_limit:
297
- logger.info(f"⏭️ {platform}: Upload limit reached ({upload_limit}), skipping")
298
  continue
299
 
300
- account_id = account_info.get("name", "")
 
 
301
 
302
- logger.info(f"πŸ“€ Publishing to {platform} ({account_id})...")
 
 
 
303
 
304
- result = publish_to_platform(
305
- platform=platform,
306
- video_path=local_path,
307
- gcs_filename=gcs_filename,
308
- account_id=account_id,
309
- caption=caption
 
310
  )
311
 
312
- # Log result
313
- log_publish_result(gcs_filename, platform, result, account_id)
 
 
 
314
 
315
- if result.get("success"):
316
- logger.info(f"βœ… {platform}: {result.get('url', 'Published')}")
317
- video_success = True
318
- platform_upload_counts[platform] = platform_upload_counts.get(platform, 0) + 1
319
- else:
320
- logger.warning(f"⚠️ {platform}: {result.get('error', 'Failed')}")
321
-
322
- # Stop if all platforms reached limit
323
- if upload_limit is not None:
324
- active_platforms = [p for p in platform_accounts if platform_upload_counts.get(p, 0) < upload_limit]
325
- if not active_platforms:
326
- logger.info("🏁 All platforms reached upload limits. Stopping.")
327
- break
328
-
329
- if video_success:
 
 
330
  success_count += 1
331
-
332
- # Cleanup downloaded file
333
- try:
334
  if os.path.exists(local_path):
335
  os.remove(local_path)
336
- except:
337
- pass
338
-
339
- logger.info(f"\n🏁 Publishing complete. {success_count}/{len(unpublished)} videos processed successfully.")
340
-
341
 
342
- def pre_check():
343
- """Validate required environment variables defined in publisher.env."""
344
-
345
- # Path to publisher.env (one level up from this file, then in root)
346
- # publisher.py is in social_media_publishers/
347
- # publisher.env is in root/
348
- env_path = os.path.join(os.path.dirname(__file__), '..', 'publisher.env')
349
-
350
- if not os.path.exists(env_path):
351
- logger.warning(f"⚠️ publisher.env not found at {env_path}. Skipping dynamic validation.")
352
- return
353
 
354
- missing = []
355
-
356
- try:
357
- with open(env_path, 'r') as f:
358
- for line in f:
359
- line = line.strip()
360
- if not line or line.startswith('#'):
361
- continue
362
-
363
- # Extract key (before the first =)
364
- if '=' in line:
365
- key = line.split('=')[0].strip()
366
- # Use get_config_value because not all keys are mapped in config.py
367
- if key and not get_config_value(key):
368
- missing.append(key)
369
-
370
- except Exception as e:
371
- logger.error(f"❌ Failed to read publisher.env: {e}")
372
- sys.exit(1)
373
 
374
- if missing:
375
- logger.error(f"❌ Missing required environment variables: {', '.join(missing)}")
376
- sys.exit(1)
377
-
378
- logger.info("βœ… Environment validation passed")
379
 
380
 
381
  if __name__ == "__main__":
382
  pre_check()
383
-
384
- # Get limit from env (workflow input)
385
- limit = get_upload_limit()
386
-
387
- if limit is not None:
388
- logger.info(f"πŸš€ Starting Publisher Workflow (Limit: {limit} per platform)...")
389
- else:
390
- logger.info("πŸš€ Starting Publisher Workflow (No Limit)...")
391
-
392
- try:
393
- run_publisher()
394
- except Exception as e:
395
- logger.error(f"❌ Publisher Failed: {e}")
396
- sys.exit(1)
 
1
  """
2
  publisher.py
3
+ Unified social media publisher using OneUp API.
4
+ Lists videos from GCS, generates public URLs, and publishes via OneUp.
 
5
  """
6
 
7
  import sys
 
17
  from src.google_src.gcs_utils import list_gcs_files, find_and_download_gcs_file, upload_file_to_gcs
18
  from src.google_src.google_sheet import GoogleSheetReader
19
  from src.utils import logger
20
+ from social_media_publishers.oneup_client import OneUpClient
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  # GSheet columns for publisher logs
23
  LOG_HEADER = ["Timestamp", "Platform", "Published File", "Published URL", "Account", "Status", "Error"]
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
26
  def get_publisher_logs_worksheet() -> GoogleSheetReader:
27
  """Get GoogleSheetReader for publisher logs worksheet."""
28
  worksheet_name = get_config_value("publisher_logs_worksheet")
 
50
 
51
  def log_publish_result(
52
  gcs_filename: str,
 
53
  result: Dict[str, Any],
54
+ account_info: str = "OneUp"
55
  ):
56
  """Log publish result to Google Sheets."""
57
  try:
58
  reader = get_publisher_logs_worksheet()
59
 
60
+ # OneUp returns error inside the dict sometimes, checking success key if exists or manual check
61
+ # OneUp response: {"message": "...", "error": false, ...}
62
+
63
+ is_error = result.get("error", False)
64
+ status = "success" if not is_error else "error"
65
+ error_msg = result.get("message") if is_error else ""
66
+
67
+ # Determine published URL (OneUp doesn't return the post URL immediately always,
68
+ # so we log the message or generic success)
69
+ published_url = "Scheduled/Published via OneUp"
70
 
71
  reader.create_or_update_sheet(
72
  header=LOG_HEADER,
73
  values=[{
74
  "Timestamp": datetime.now().isoformat(),
75
+ "Platform": "OneUp (All)",
76
  "Published File": gcs_filename,
77
  "Published URL": published_url,
78
+ "Account": account_info,
79
  "Status": status,
80
  "Error": error_msg
81
  }]
82
  )
83
+ logger.info(f"βœ“ Logged result to GSheet")
84
  except Exception as e:
85
  logger.error(f"❌ Failed to log to GSheet: {e}")
86
 
87
 
88
+ def run_publisher():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  """
90
+ Main publisher workflow using OneUp:
91
+ 1. Initialize OneUp Client.
92
+ 2. Get first category and target accounts ["ALL"].
93
+ 3. List videos from GCS.
94
+ 4. Check which are already published.
95
+ 5. For each unpublished video:
96
+ - Download (to verify/process if needed, or simply get GCS public link).
97
+ - Ensure public URL exists (upload to GCS public bucket/path).
98
+ - Publish via OneUp (publish_now=True).
99
+ - Log results.
100
  """
101
+ logger.info("πŸš€ Starting OneUp Publisher Integration...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
+ # Initialize Client
104
  try:
105
+ client = OneUpClient()
106
+ except Exception as e:
107
+ logger.error(f"❌ Failed to initialize OneUpClient: {e}")
108
+ return
 
 
 
 
 
 
109
 
110
+ # 1. Get Category (Default: First one)
 
 
 
 
 
 
 
111
  try:
112
+ categories = client.get_categories()
113
+ if not categories:
114
+ logger.error("❌ No categories found in OneUp account.")
115
+ return
116
 
117
+ target_category = categories[0]
118
+ category_id = target_category['id']
119
+ category_name = target_category['category_name']
120
+ logger.info(f"βœ… Selected Category: {category_name} (ID: {category_id})")
121
 
122
+ except Exception as e:
123
+ logger.error(f"❌ Failed to fetch categories: {e}")
124
+ return
125
+
126
+ # 2. Target Accounts
127
+ # Strategy: "all the accounts in the category" -> pass ["ALL"]
128
+ # We can fetch accounts just to log them for visibility
129
+ try:
130
+ accounts = client.get_accounts_in_category(category_id)
131
+ account_names = [f"{a.get('social_network_name')} ({a.get('social_network_type')})" for a in accounts]
132
+ logger.info(f"πŸ‘₯ Target Accounts in Category ({len(accounts)}): {', '.join(account_names)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ if not accounts:
135
+ logger.warning("⚠️ No accounts found in this category. Publishing might fail.")
136
+
137
  except Exception as e:
138
+ logger.warning(f"⚠️ Could not list accounts (proceeding with 'ALL'): {e}")
 
139
 
140
+ target_social_network_ids = ["ALL"] # As per requirement/spec
141
 
142
+ # 3. Get / Filter Videos
 
 
 
 
 
 
 
 
 
 
143
  gcs_files = list_gcs_files()
144
  logger.info(f"πŸ“ Found {len(gcs_files)} videos in GCS")
145
 
 
147
  logger.info("βœ… No videos to publish")
148
  return
149
 
 
150
  published = load_published_files()
 
 
151
  unpublished = [f for f in gcs_files if f not in published]
152
  logger.info(f"πŸ†• {len(unpublished)} unpublished videos to process")
153
 
154
  if not unpublished:
155
  logger.info("βœ… All videos already published")
156
  return
157
+
158
+ # 4. Publish Workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  success_count = 0
160
 
161
  for idx, gcs_filename in enumerate(unpublished):
 
163
  logger.info(f"πŸ“Ί [{idx+1}/{len(unpublished)}] Processing: {gcs_filename}")
164
  logger.info(f"{'='*50}")
165
 
166
+ try:
167
+ # We need a PUBLIC URL for OneUp.
168
+ # Existing GCS files might be private.
169
+ # We will download it and re-upload to a public path (or generate a signed URL if OneUp accepts it).
170
+ # OneUp spec says: "video_url must be publicly accessible".
171
+ # Signed URLs work but are temporary. Public bucket is better.
172
+
173
+ # Download first to a local temp path
174
+ local_path = find_and_download_gcs_file(gcs_filename)
175
+ if not local_path or not os.path.exists(local_path):
176
+ logger.error(f"❌ Failed to download: {gcs_filename}")
 
 
 
 
 
 
 
177
  continue
178
 
179
+ # Generate Caption
180
+ basename = os.path.basename(gcs_filename)
181
+ caption = os.path.splitext(basename)[0].replace("_", " ")[:200]
182
 
183
+ # Re-upload to GCS (Public)
184
+ # We use a specific public bucket if configured, or same bucket but rely on public URL construction
185
+ # reusing existing logic for Instagram
186
+ logger.info("☁️ Uploading valid public link for OneUp...")
187
 
188
+ uploaded = upload_file_to_gcs(
189
+ local_path,
190
+ account_name="final_data",
191
+ bucket_name=get_config_value("GCS_BUCKET_NAME_PUBLIC_FOR_SOCIAL_PUBLISH") or get_config_value("GCS_BUCKET_NAME"),
192
+ generate_signed_url=True, # User requested signed URL preference
193
+ fallback_to_drive=False,
194
+ save_in_drive_also=False
195
  )
196
 
197
+ video_url = uploaded.get("signed_url")
198
+ if not video_url:
199
+ # If public_url is empty, maybe try 'url' (signed)
200
+ video_url = uploaded.get("url")
201
+ logger.warning("⚠️ Using signed URL (might expire). Ensure bucket is public for best results.")
202
 
203
+ logger.info(f"πŸ”— Video URL: {video_url}")
204
+
205
+ # Publish
206
+ logger.info("πŸ“€ Sending to OneUp...")
207
+ result = client.schedule_video_post(
208
+ category_id=category_id,
209
+ social_network_ids=target_social_network_ids,
210
+ video_url=video_url,
211
+ title=caption,
212
+ content=caption,
213
+ publish_now=True
214
+ )
215
+
216
+ logger.info(f"βœ… OneUp Response: {result.get('message')}")
217
+
218
+ # Log success
219
+ log_publish_result(gcs_filename, result, f"Category: {category_name}")
220
  success_count += 1
221
+
222
+ # Cleanup
 
223
  if os.path.exists(local_path):
224
  os.remove(local_path)
225
+
226
+ except Exception as e:
227
+ logger.error(f"❌ Failed to publish {gcs_filename}: {e}")
228
+ log_publish_result(gcs_filename, {"error": True, "message": str(e)}, "Error")
 
229
 
230
+ logger.info(f"\n🏁 Publishing complete. {success_count}/{len(unpublished)} videos processed.")
 
 
 
 
 
 
 
 
 
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
+ def pre_check():
234
+ """Environment check."""
235
+ if not get_config_value("ONEUP_API_KEY"):
236
+ logger.error("❌ ONEUP_API_KEY is missing in configuration.")
237
+ sys.exit(1)
238
 
239
 
240
  if __name__ == "__main__":
241
  pre_check()
242
+ run_publisher()
 
 
 
 
 
 
 
 
 
 
 
 
 
src/google_src/gcs_utils.py CHANGED
@@ -176,6 +176,7 @@ def upload_file_to_gcs(
176
  )
177
  logger.info(f"βœ… Signed URL generated")
178
  result["url"] = signed_url
 
179
  except Exception as e:
180
  logger.warning(f"⚠️ Failed to generate signed URL (using public URL): {e}")
181
 
 
176
  )
177
  logger.info(f"βœ… Signed URL generated")
178
  result["url"] = signed_url
179
+ result["signed_url"] = signed_url
180
  except Exception as e:
181
  logger.warning(f"⚠️ Failed to generate signed URL (using public URL): {e}")
182