jebin2 commited on
Commit
63b33ed
Β·
1 Parent(s): 9af4001

x integration testing.

Browse files
social_media_publishers/app.py CHANGED
@@ -132,6 +132,8 @@ async def oauth2callback(request: Request):
132
  platform = 'tiktok'
133
  elif state.startswith('threads_'):
134
  platform = 'threads'
 
 
135
  else:
136
  platform = 'youtube' # Default for backward compatibility
137
 
@@ -291,11 +293,7 @@ async def publish_video(
291
 
292
 
293
  # 1. Get Publisher
294
- publisher = PublisherFactory.get_publisher(platform)
295
-
296
  # 2. Authenticate
297
- publisher.authenticate(account_id=account_email)
298
-
299
  # 3. Publish
300
  is_public = (privacy == 'public')
301
  privacy_level = 'PUBLIC_TO_EVERYONE' if is_public else 'SELF_ONLY'
@@ -330,37 +328,80 @@ async def publish_video(
330
  # --- ONEUP PROVIDER FLOW ---
331
  if provider == 'oneup':
332
  from social_media_publishers.oneup_service import OneUpService
333
-
334
  try:
335
  service = OneUpService()
336
- # Use account_email as the identifier (it might be username/email/name)
337
- # If None, it will fail inside service if strictly required, or fallback if designed.
338
  result = service.publish_video(
339
  platform=platform,
340
  content_path=content_path,
341
  metadata=metadata,
342
  account_identifier=account_email
343
  )
344
-
345
  if result.get('error'):
346
  return JSONResponse(content=result, status_code=400)
347
-
348
  return result
349
-
350
  except Exception as e:
351
  logger.error(f"OneUp Publish Error: {e}", exc_info=True)
352
  return JSONResponse(content={'error': f"OneUp Error: {str(e)}"}, status_code=500)
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
- # --- DIRECT API FLOW (Default) ---
356
-
357
- logger.info(f"πŸš€ Publishing to {platform}: {title}")
358
- result = publisher.publish(content_path, metadata)
359
-
360
- if result.get('error'):
361
- return JSONResponse(content=result, status_code=400)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
- return result
 
364
 
365
  except Exception as e:
366
  logger.error(f"Publish Error: {e}", exc_info=True)
 
132
  platform = 'tiktok'
133
  elif state.startswith('threads_'):
134
  platform = 'threads'
135
+ elif state.startswith('twitter_'):
136
+ platform = 'twitter'
137
  else:
138
  platform = 'youtube' # Default for backward compatibility
139
 
 
293
 
294
 
295
  # 1. Get Publisher
 
 
296
  # 2. Authenticate
 
 
297
  # 3. Publish
298
  is_public = (privacy == 'public')
299
  privacy_level = 'PUBLIC_TO_EVERYONE' if is_public else 'SELF_ONLY'
 
328
  # --- ONEUP PROVIDER FLOW ---
329
  if provider == 'oneup':
330
  from social_media_publishers.oneup_service import OneUpService
 
331
  try:
332
  service = OneUpService()
 
 
333
  result = service.publish_video(
334
  platform=platform,
335
  content_path=content_path,
336
  metadata=metadata,
337
  account_identifier=account_email
338
  )
 
339
  if result.get('error'):
340
  return JSONResponse(content=result, status_code=400)
 
341
  return result
 
342
  except Exception as e:
343
  logger.error(f"OneUp Publish Error: {e}", exc_info=True)
344
  return JSONResponse(content={'error': f"OneUp Error: {str(e)}"}, status_code=500)
345
 
346
+ # --- DIRECT API FLOW ---
347
+ # 1. Get Publisher
348
+ logger.info(f"πŸ“ STEP 1: Getting Publisher for platform {platform}")
349
+ publisher = PublisherFactory.get_publisher(platform)
350
+ if not publisher:
351
+ logger.error(f"❌ Publisher for {platform} not found")
352
+ return JSONResponse(content={"error": f"Publisher for {platform} not found"}, status_code=404)
353
+
354
+ # 2. Resolve target accounts
355
+ logger.info(f"πŸ“ STEP 2: Resolving target accounts (Select: {account_email})")
356
+ target_accounts = []
357
+ if account_email == 'all':
358
+ creator = PublisherFactory.get_auth_creator(platform)
359
+ if creator:
360
+ accounts_info = creator.list_connected_accounts()
361
+ for acc in accounts_info:
362
+ # Clean ID extractor
363
+ clean_id = acc['filename'].replace(f"{platform}_token_", '').replace('.json', '')
364
+ target_accounts.append(clean_id)
365
+
366
+ if not target_accounts:
367
+ logger.warning(f"⚠️ No connected accounts found for {platform}")
368
+ return JSONResponse(content={"error": f"No connected accounts found for {platform}"}, status_code=400)
369
+ else:
370
+ target_accounts = [account_email]
371
 
372
+ # 3. Iterate and Publish
373
+ logger.info(f"πŸš€ STEP 3: Starting publishing loop for {len(target_accounts)} accounts")
374
+ results = []
375
+ for i, acc_id in enumerate(target_accounts, 1):
376
+ logger.info(f"⏳ [{i}/{len(target_accounts)}] Processing account: {acc_id}")
377
+ try:
378
+ # 3a. Authenticate
379
+ logger.info(f" πŸ”‘ Authenticating {acc_id}...")
380
+ publisher.authenticate(account_id=acc_id)
381
+
382
+ # 3b. Publish
383
+ logger.info(f" ⬆️ Publishing to {acc_id} (Title: {title})...")
384
+ res = publisher.publish(content_path, metadata)
385
+
386
+ if res.get("success"):
387
+ logger.info(f" βœ… Successfully posted to {acc_id}! Post ID: {res.get('post_id') or res.get('media_id')}")
388
+ else:
389
+ logger.error(f" ❌ Failed to post to {acc_id}: {res.get('error')}")
390
+
391
+ results.append({"account": acc_id, "success": res.get("success", False), "result": res})
392
+ except Exception as e:
393
+ logger.error(f" πŸ’₯ Fatal error for {acc_id}: {e}", exc_info=True)
394
+ results.append({"account": acc_id, "success": False, "error": str(e)})
395
+
396
+ # Return single result if only one account, else list
397
+ if len(results) == 1:
398
+ final_res = results[0]["result"] if results[0]["success"] else {"success": False, "error": results[0].get("error") or results[0]["result"].get("error")}
399
+ if not results[0]["success"]:
400
+ return JSONResponse(content=final_res, status_code=400)
401
+ return final_res
402
 
403
+ logger.info(f"🏁 Finished publishing loop. Results: {results}")
404
+ return {"success": True, "multi_results": results}
405
 
406
  except Exception as e:
407
  logger.error(f"Publish Error: {e}", exc_info=True)
social_media_publishers/factory.py CHANGED
@@ -1,8 +1,6 @@
1
- from .youtube.auth import YoutubeAuthCreator
2
- from .instagram.auth import InstagramAuthCreator
3
- from .facebook.auth import FacebookAuthCreator
4
  from .threads.auth import ThreadsAuthCreator
5
  from .tiktok.auth import TikTokAuthCreator
 
6
 
7
  class PublisherFactory:
8
  """Factory to create social media auth creators and publishers."""
@@ -20,6 +18,8 @@ class PublisherFactory:
20
  return TikTokAuthCreator()
21
  if platform_lower == 'threads':
22
  return ThreadsAuthCreator()
 
 
23
  raise ValueError(f"Unknown platform: {platform}")
24
 
25
  @staticmethod
@@ -40,6 +40,9 @@ class PublisherFactory:
40
  if platform_lower == 'threads':
41
  from .threads.publisher import ThreadsPublisher
42
  return ThreadsPublisher()
 
 
 
43
  raise ValueError(f"Unknown platform: {platform}")
44
 
45
 
 
 
 
 
1
  from .threads.auth import ThreadsAuthCreator
2
  from .tiktok.auth import TikTokAuthCreator
3
+ from .twitter.auth import TwitterAuthCreator
4
 
5
  class PublisherFactory:
6
  """Factory to create social media auth creators and publishers."""
 
18
  return TikTokAuthCreator()
19
  if platform_lower == 'threads':
20
  return ThreadsAuthCreator()
21
+ if platform_lower == 'twitter':
22
+ return TwitterAuthCreator()
23
  raise ValueError(f"Unknown platform: {platform}")
24
 
25
  @staticmethod
 
40
  if platform_lower == 'threads':
41
  from .threads.publisher import ThreadsPublisher
42
  return ThreadsPublisher()
43
+ if platform_lower == 'twitter':
44
+ from .twitter.publisher import TwitterPublisher
45
+ return TwitterPublisher()
46
  raise ValueError(f"Unknown platform: {platform}")
47
 
48
 
social_media_publishers/frontend/src/components/GlobalUploadModal.jsx ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Upload, X, Calendar, Check, Globe, Layout, Smartphone, Share2, AlertCircle, Youtube, Twitter as TwitterIcon, Music2, Instagram, Facebook, AtSign, Loader2 } from 'lucide-react';
3
+ import axios from 'axios';
4
+
5
+ const API_URL = '/social/api';
6
+
7
+ const AVAILABLE_PLATFORMS = [
8
+ { id: 'youtube', name: 'YouTube', icon: Youtube },
9
+ { id: 'tiktok', name: 'TikTok', icon: Music2 },
10
+ { id: 'instagram', name: 'Instagram', icon: Instagram },
11
+ { id: 'facebook', name: 'Facebook', icon: Facebook },
12
+ { id: 'threads', name: 'Threads', icon: AtSign },
13
+ { id: 'twitter', name: 'X (Twitter)', icon: TwitterIcon },
14
+ ];
15
+
16
+ export default function GlobalUploadModal({ onClose, onSuccess }) {
17
+ const [selectedPlatforms, setSelectedPlatforms] = useState(['youtube']);
18
+ const [provider, setProvider] = useState('direct'); // Global provider for now
19
+
20
+ const [file, setFile] = useState(null);
21
+ const [title, setTitle] = useState('');
22
+ const [description, setDescription] = useState('');
23
+ const [privacy, setPrivacy] = useState('private');
24
+ const [scheduledTime, setScheduledTime] = useState('');
25
+
26
+ const [loading, setLoading] = useState(false);
27
+ const [error, setError] = useState(null);
28
+ const [results, setResults] = useState(null);
29
+
30
+ const [inputType, setInputType] = useState('file'); // 'file' or 'url'
31
+ const [videoUrl, setVideoUrl] = useState('');
32
+
33
+ const togglePlatform = (id) => {
34
+ setSelectedPlatforms(prev =>
35
+ prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
36
+ );
37
+ };
38
+
39
+ const handleSubmit = async (e) => {
40
+ e.preventDefault();
41
+
42
+ if (selectedPlatforms.length === 0) return setError("Select at least one platform");
43
+ if (inputType === 'file' && !file) return setError("File is required");
44
+ if (inputType === 'url' && !videoUrl) return setError("Video URL is required");
45
+ if (!title) return setError("Title is required");
46
+
47
+ setLoading(true);
48
+ setError(null);
49
+ setResults([]); // Initialize empty results list
50
+
51
+ const uploadResults = [];
52
+
53
+ // Iterate through platforms
54
+ for (const platform of selectedPlatforms) {
55
+ // Update UI to show "Processing" for this specific platform
56
+ setResults(prev => [...prev.filter(r => r.platform !== platform), { platform, status: 'processing', success: false }]);
57
+
58
+ const formData = new FormData();
59
+ formData.append('platform', platform);
60
+ formData.append('provider', provider);
61
+ formData.append('account_email', 'all'); // Post to all accounts
62
+
63
+ if (inputType === 'file') formData.append('file', file);
64
+ else formData.append('video_url', videoUrl);
65
+
66
+ formData.append('title', title);
67
+ formData.append('description', description);
68
+ formData.append('privacy', privacy);
69
+ if (scheduledTime) formData.append('scheduled_time', new Date(scheduledTime).toISOString());
70
+
71
+ try {
72
+ const response = await axios.post(`${API_URL}/publish`, formData, {
73
+ headers: { 'Content-Type': 'multipart/form-data' }
74
+ });
75
+
76
+ const platformResult = {
77
+ platform,
78
+ status: 'completed',
79
+ success: true,
80
+ data: response.data
81
+ };
82
+ uploadResults.push(platformResult);
83
+ setResults(prev => [...prev.filter(r => r.platform !== platform), platformResult]);
84
+ } catch (err) {
85
+ console.error(`Upload to ${platform} failed`, err);
86
+ const platformError = {
87
+ platform,
88
+ status: 'failed',
89
+ success: false,
90
+ error: err.response?.data?.error || "Upload failed"
91
+ };
92
+ uploadResults.push(platformError);
93
+ setResults(prev => [...prev.filter(r => r.platform !== platform), platformError]);
94
+ }
95
+ }
96
+
97
+ // Final summary check
98
+ const allSuccess = uploadResults.every(r => r.success);
99
+ setLoading(false);
100
+
101
+ if (allSuccess) {
102
+ setTimeout(() => {
103
+ onSuccess(uploadResults);
104
+ onClose();
105
+ }, 5000); // 5s to let them see all green
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 transition-all animate-in fade-in duration-300">
111
+ <div className="bg-surface border border-border shadow-[0_0_50px_-12px_rgba(0,0,0,0.5)] rounded-3xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col animate-in zoom-in-95 duration-300">
112
+
113
+ {/* Header */}
114
+ <div className="p-6 border-b border-border flex items-center justify-between bg-white/50 backdrop-blur-sm sticky top-0 z-10">
115
+ <div>
116
+ <h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
117
+ <div className="p-2 rounded-xl bg-primary/10 text-primary">
118
+ <Globe className="w-6 h-6" />
119
+ </div>
120
+ Global Publisher
121
+ </h2>
122
+ <p className="text-sm text-secondary mt-1">Scedule & post to all your connected accounts at once.</p>
123
+ </div>
124
+ <button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors">
125
+ <X className="w-6 h-6 text-gray-400" />
126
+ </button>
127
+ </div>
128
+
129
+ <div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
130
+ <form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-10">
131
+ {/* Left Column: Config */}
132
+ <div className="space-y-8">
133
+ {/* Platform Selection */}
134
+ <div className="space-y-4">
135
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest flex items-center gap-2">
136
+ <Globe className="w-3 h-3" /> Select Platforms
137
+ </label>
138
+ <div className="grid grid-cols-2 gap-3">
139
+ {AVAILABLE_PLATFORMS.map(p => (
140
+ <button
141
+ key={p.id}
142
+ type="button"
143
+ onClick={() => togglePlatform(p.id)}
144
+ className={`flex items-center gap-3 p-3 rounded-2xl border transition-all ${selectedPlatforms.includes(p.id)
145
+ ? 'bg-primary/5 border-primary text-primary shadow-sm'
146
+ : 'bg-white border-border text-slate-600 hover:border-slate-300'
147
+ }`}
148
+ >
149
+ <div className={`w-8 h-8 rounded-lg flex items-center justify-center ${selectedPlatforms.includes(p.id) ? 'bg-primary text-white' : 'bg-slate-100 text-slate-500'}`}>
150
+ <p.icon className="w-5 h-5" />
151
+ </div>
152
+ <span className="font-semibold text-sm">{p.name}</span>
153
+ </button>
154
+ ))}
155
+ </div>
156
+ </div>
157
+
158
+ {/* Provider Toggle */}
159
+ <div className="space-y-4">
160
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest flex items-center gap-2">
161
+ <Share2 className="w-3 h-3" /> Publishing Flow
162
+ </label>
163
+ <div className="flex p-1.5 bg-slate-100 rounded-2xl border border-border">
164
+ <button
165
+ type="button"
166
+ onClick={() => setProvider('direct')}
167
+ className={`flex-1 py-3 text-sm font-bold rounded-xl transition-all ${provider === 'direct' ? 'bg-white text-slate-900 shadow-md' : 'text-slate-500 hover:text-slate-900'}`}
168
+ >
169
+ Direct API
170
+ </button>
171
+ <button
172
+ type="button"
173
+ onClick={() => setProvider('oneup')}
174
+ className={`flex-1 py-3 text-sm font-bold rounded-xl transition-all ${provider === 'oneup' ? 'bg-white text-slate-900 shadow-md' : 'text-slate-500 hover:text-slate-900'}`}
175
+ >
176
+ OneUp
177
+ </button>
178
+ </div>
179
+ <p className="text-[10px] text-slate-400 px-2 italic">
180
+ {provider === 'direct' ? 'Posts directly via official APIs. Best for immediate results.' : 'Schedules via OneUp. Best for avoiding API tier restrictions.'}
181
+ </p>
182
+ </div>
183
+
184
+ {/* Media Type */}
185
+ <div className="space-y-4">
186
+ <div className="flex bg-slate-100 p-1.5 rounded-2xl border border-border">
187
+ <button type="button" onClick={() => setInputType('file')} className={`flex-1 py-2.5 text-xs font-bold rounded-xl transition-all ${inputType === 'file' ? 'bg-primary text-white shadow-lg' : 'text-slate-500'}`}>File Upload</button>
188
+ <button type="button" onClick={() => setInputType('url')} className={`flex-1 py-2.5 text-xs font-bold rounded-xl transition-all ${inputType === 'url' ? 'bg-primary text-white shadow-lg' : 'text-slate-500'}`}>Direct URL</button>
189
+ </div>
190
+
191
+ {inputType === 'file' ? (
192
+ <div className="border-2 border-dashed border-slate-200 rounded-3xl p-8 text-center hover:border-primary/50 transition-colors bg-slate-50/50 group cursor-pointer relative">
193
+ <div className="w-16 h-16 bg-white rounded-2xl shadow-sm flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
194
+ <Upload className="w-8 h-8 text-primary" />
195
+ </div>
196
+ <p className="text-sm font-bold text-slate-900 mb-1">{file ? file.name : 'Select video or image'}</p>
197
+ <p className="text-xs text-slate-400">{file ? `${(file.size / (1024 * 1024)).toFixed(1)} MB` : 'Drag and drop your file here'}</p>
198
+ <input
199
+ type="file"
200
+ accept="video/*,image/*"
201
+ onChange={(e) => setFile(e.target.files?.[0])}
202
+ className="absolute inset-0 opacity-0 cursor-pointer z-10"
203
+ />
204
+ </div>
205
+ ) : (
206
+ <input
207
+ type="url"
208
+ value={videoUrl}
209
+ onChange={(e) => setVideoUrl(e.target.value)}
210
+ placeholder="https://example.com/video.mp4"
211
+ className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none font-medium text-sm"
212
+ />
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ {/* Right Column: Metadata */}
218
+ <div className="space-y-6">
219
+ <div className="space-y-2">
220
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Title</label>
221
+ <input
222
+ type="text"
223
+ value={title}
224
+ onChange={(e) => setTitle(e.target.value)}
225
+ placeholder="Enter post title..."
226
+ className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none font-bold text-slate-900"
227
+ />
228
+ </div>
229
+
230
+ <div className="space-y-2">
231
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Description</label>
232
+ <textarea
233
+ value={description}
234
+ onChange={(e) => setDescription(e.target.value)}
235
+ placeholder="Add description and hashtags..."
236
+ className="w-full bg-white border border-border rounded-3xl px-5 py-4 h-48 resize-none focus:ring-2 focus:ring-primary/20 outline-none text-slate-600 leading-relaxed"
237
+ />
238
+ </div>
239
+
240
+ <div className="grid grid-cols-2 gap-4">
241
+ <div className="space-y-2">
242
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Privacy</label>
243
+ <select
244
+ value={privacy}
245
+ onChange={(e) => setPrivacy(e.target.value)}
246
+ className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none appearance-none font-semibold text-slate-900"
247
+ >
248
+ <option value="private">Private</option>
249
+ <option value="public">Public</option>
250
+ <option value="unlisted">Unlisted</option>
251
+ </select>
252
+ </div>
253
+ <div className="space-y-2">
254
+ <label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1 flex items-center gap-1.5"><Calendar className="w-3 h-3" /> Schedule</label>
255
+ <input
256
+ type="datetime-local"
257
+ value={scheduledTime}
258
+ onChange={(e) => setScheduledTime(e.target.value)}
259
+ className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none text-sm font-semibold text-slate-900"
260
+ />
261
+ </div>
262
+ </div>
263
+ </div>
264
+ </form>
265
+ </div>
266
+
267
+ {/* Footer */}
268
+ <div className="p-8 border-t border-border bg-slate-50/50 flex flex-col gap-6">
269
+ {results && results.length > 0 && (
270
+ <div className="space-y-3">
271
+ <label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest pl-1">Processing Status</label>
272
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
273
+ {results.map(r => (
274
+ <div key={r.platform} className={`flex items-center justify-between gap-2 px-4 py-3 rounded-2xl border transition-all ${r.status === 'completed' ? 'bg-emerald-50 border-emerald-200 text-emerald-700 shadow-sm' :
275
+ r.status === 'failed' ? 'bg-red-50 border-red-200 text-red-700' :
276
+ 'bg-blue-50 border-blue-200 text-blue-700 animate-pulse'
277
+ }`}>
278
+ <div className="flex items-center gap-3 min-w-0">
279
+ {r.status === 'completed' && <div className="p-1 bg-emerald-500 rounded-full text-white"><Check className="w-3 h-3" /></div>}
280
+ {r.status === 'failed' && <div className="p-1 bg-red-500 rounded-full text-white"><X className="w-3 h-3" /></div>}
281
+ {r.status === 'processing' && <Loader2 className="w-4 h-4 animate-spin" />}
282
+ <span className="font-bold text-sm capitalize truncate">{r.platform}</span>
283
+ </div>
284
+ <div className="text-[10px] font-medium opacity-80 italic">
285
+ {r.status === 'completed' ? (r.data?.multi_results ? `Posted to ${r.data.multi_results.length} accounts` : 'Success') :
286
+ r.status === 'failed' ? r.error :
287
+ 'Publishing...'}
288
+ </div>
289
+ </div>
290
+ ))}
291
+ </div>
292
+ </div>
293
+ )}
294
+
295
+ {error && (
296
+ <div className="p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl text-sm font-bold flex items-center gap-3 animate-head-shake">
297
+ <AlertCircle className="w-5 h-5" />
298
+ {error}
299
+ </div>
300
+ )}
301
+
302
+ <div className="flex items-center gap-4">
303
+ <button
304
+ onClick={onClose}
305
+ className="px-8 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-200 transition-colors"
306
+ >
307
+ Cancel
308
+ </button>
309
+ <button
310
+ onClick={handleSubmit}
311
+ disabled={loading}
312
+ className="flex-1 bg-primary hover:bg-primary/90 text-white font-bold py-4 rounded-2xl transition-all shadow-xl shadow-primary/20 hover:shadow-primary/30 disabled:opacity-50 flex items-center justify-center gap-3 text-lg"
313
+ >
314
+ {loading ? (
315
+ <div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
316
+ ) : (
317
+ <Share2 className="w-6 h-6" />
318
+ )}
319
+ {loading ? 'Processing Queue...' : `Publish to ${selectedPlatforms.length} Platforms`}
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ );
326
+ }
social_media_publishers/frontend/src/components/TikTokUploadModal.jsx CHANGED
@@ -11,6 +11,7 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
11
  const [description, setDescription] = useState('');
12
  const [inputType, setInputType] = useState('file'); // 'file' or 'url'
13
  const [videoUrl, setVideoUrl] = useState('');
 
14
  const [loading, setLoading] = useState(false);
15
  const [error, setError] = useState(null);
16
  const videoRef = useRef(null);
@@ -110,6 +111,7 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
110
 
111
  const formData = new FormData();
112
  formData.append('platform', platform);
 
113
  if (inputType === 'file') formData.append('file', file);
114
  else formData.append('video_url', videoUrl);
115
 
@@ -204,8 +206,22 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
204
 
205
  <form onSubmit={handleSubmit} className="flex flex-col lg:flex-row h-full overflow-hidden">
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  {/* LEFT COLUMN: Media Only */}
208
- <div className="w-full lg:w-1/2 p-5 lg:border-r border-border overflow-y-auto custom-scrollbar flex flex-col justify-center">
209
  {/* 1. Media */}
210
  <div className="space-y-3 w-full flex flex-col items-center">
211
  <div className="flex items-center justify-between w-full max-w-[260px]">
 
11
  const [description, setDescription] = useState('');
12
  const [inputType, setInputType] = useState('file'); // 'file' or 'url'
13
  const [videoUrl, setVideoUrl] = useState('');
14
+ const [provider, setProvider] = useState('direct'); // 'direct' or 'oneup'
15
  const [loading, setLoading] = useState(false);
16
  const [error, setError] = useState(null);
17
  const videoRef = useRef(null);
 
111
 
112
  const formData = new FormData();
113
  formData.append('platform', platform);
114
+ formData.append('provider', provider);
115
  if (inputType === 'file') formData.append('file', file);
116
  else formData.append('video_url', videoUrl);
117
 
 
206
 
207
  <form onSubmit={handleSubmit} className="flex flex-col lg:flex-row h-full overflow-hidden">
208
 
209
+ {/* Provider Selection Bar */}
210
+ <div className="lg:absolute lg:top-14 lg:left-0 lg:right-0 bg-slate-100/50 px-5 py-1.5 border-b border-border flex items-center gap-3 shrink-0 z-10">
211
+ <div className="text-[10px] font-semibold text-secondary uppercase tracking-wider">Provider</div>
212
+ <div className="flex bg-white p-0.5 rounded-lg border border-border">
213
+ <button type="button" onClick={() => setProvider('direct')} className={`px-2 py-0.5 text-[10px] font-medium rounded-md transition-all ${provider === 'direct' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'}`}>Direct API</button>
214
+ <button type="button" onClick={() => setProvider('oneup')} className={`px-2 py-0.5 text-[10px] font-medium rounded-md transition-all ${provider === 'oneup' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'}`}>OneUp</button>
215
+ </div>
216
+ {provider === 'oneup' && (
217
+ <div className="text-[9px] text-amber-600 flex items-center gap-1 bg-amber-50 px-2 py-0.5 rounded">
218
+ <Info className="w-2.5 h-2.5" /> Note: Advanced settings (duet/stitch/branded) may be ignored by OneUp.
219
+ </div>
220
+ )}
221
+ </div>
222
+
223
  {/* LEFT COLUMN: Media Only */}
224
+ <div className="w-full lg:w-1/2 p-5 lg:pt-12 lg:border-r border-border overflow-y-auto custom-scrollbar flex flex-col justify-center">
225
  {/* 1. Media */}
226
  <div className="space-y-3 w-full flex flex-col items-center">
227
  <div className="flex items-center justify-between w-full max-w-[260px]">
social_media_publishers/frontend/src/components/UploadModal.jsx CHANGED
@@ -18,6 +18,7 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
18
  const [videoUrl, setVideoUrl] = useState('');
19
  const [downloadVideo, setDownloadVideo] = useState(false);
20
  const [inputType, setInputType] = useState('file'); // 'file' or 'url'
 
21
 
22
  const handleFileChange = (e) => {
23
  if (e.target.files && e.target.files[0]) {
@@ -47,6 +48,7 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
47
 
48
  const formData = new FormData();
49
  formData.append('platform', platform);
 
50
 
51
  if (inputType === 'file') {
52
  formData.append('file', file);
@@ -102,6 +104,33 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
102
  )}
103
 
104
  <form onSubmit={handleSubmit} className="space-y-5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  {/* Input Type Toggle */}
106
  <div className="flex bg-bg p-1 rounded-xl border border-border">
107
  <button
 
18
  const [videoUrl, setVideoUrl] = useState('');
19
  const [downloadVideo, setDownloadVideo] = useState(false);
20
  const [inputType, setInputType] = useState('file'); // 'file' or 'url'
21
+ const [provider, setProvider] = useState('direct'); // 'direct' or 'oneup'
22
 
23
  const handleFileChange = (e) => {
24
  if (e.target.files && e.target.files[0]) {
 
48
 
49
  const formData = new FormData();
50
  formData.append('platform', platform);
51
+ formData.append('provider', provider);
52
 
53
  if (inputType === 'file') {
54
  formData.append('file', file);
 
104
  )}
105
 
106
  <form onSubmit={handleSubmit} className="space-y-5">
107
+ {/* Provider Selection */}
108
+ <div className="space-y-2">
109
+ <label className="text-xs font-semibold text-secondary uppercase tracking-wider">Publishing Provider</label>
110
+ <div className="flex bg-bg p-1 rounded-xl border border-border">
111
+ <button
112
+ type="button"
113
+ onClick={() => setProvider('direct')}
114
+ className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-all ${provider === 'direct'
115
+ ? 'bg-primary text-white shadow-sm'
116
+ : 'text-secondary hover:text-primary'
117
+ }`}
118
+ >
119
+ Direct API
120
+ </button>
121
+ <button
122
+ type="button"
123
+ onClick={() => setProvider('oneup')}
124
+ className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-all ${provider === 'oneup'
125
+ ? 'bg-primary text-white shadow-sm'
126
+ : 'text-secondary hover:text-primary'
127
+ }`}
128
+ >
129
+ OneUp
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
  {/* Input Type Toggle */}
135
  <div className="flex bg-bg p-1 rounded-xl border border-border">
136
  <button
social_media_publishers/frontend/src/pages/Dashboard.jsx CHANGED
@@ -1,9 +1,11 @@
1
  import React, { useState } from 'react';
2
  import { Link } from 'react-router-dom';
3
- import { Youtube, Music2, Instagram, Facebook, ArrowRight, Download, Loader2, AtSign, ShieldCheck } from 'lucide-react';
4
  import { generateDashboardReport } from '../utils/pdfGenerator';
5
  import { LoadingButton } from '../components/Skeletons';
6
  import ProgressModal from '../components/ProgressModal';
 
 
7
 
8
  const platforms = [
9
  {
@@ -55,6 +57,16 @@ const platforms = [
55
  borderColor: 'group-hover:border-slate-800/50',
56
  bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(30,41,59,0.3)]',
57
  status: 'Active'
 
 
 
 
 
 
 
 
 
 
58
  }
59
  ];
60
 
@@ -64,6 +76,7 @@ export default function Dashboard() {
64
  const [progressLogs, setProgressLogs] = useState([]);
65
  const [isComplete, setIsComplete] = useState(false);
66
  const [pdfResult, setPdfResult] = useState(null);
 
67
 
68
  const handleDownloadReport = async () => {
69
  try {
@@ -130,6 +143,14 @@ export default function Dashboard() {
130
 
131
  return (
132
  <div>
 
 
 
 
 
 
 
 
133
  <ProgressModal
134
  isOpen={showProgress}
135
  title={isComplete ? "Report Generated!" : "Generating Report..."}
@@ -193,6 +214,14 @@ export default function Dashboard() {
193
  </div>
194
 
195
  <div className="flex gap-3">
 
 
 
 
 
 
 
 
196
  <LoadingButton
197
  onClick={handleVerifyAppReview}
198
  loading={verifying}
 
1
  import React, { useState } from 'react';
2
  import { Link } from 'react-router-dom';
3
+ import { Youtube, Music2, Instagram, Facebook, ArrowRight, Download, Loader2, AtSign, ShieldCheck, Twitter } from 'lucide-react';
4
  import { generateDashboardReport } from '../utils/pdfGenerator';
5
  import { LoadingButton } from '../components/Skeletons';
6
  import ProgressModal from '../components/ProgressModal';
7
+ import GlobalUploadModal from '../components/GlobalUploadModal';
8
+ import { Upload } from 'lucide-react';
9
 
10
  const platforms = [
11
  {
 
57
  borderColor: 'group-hover:border-slate-800/50',
58
  bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(30,41,59,0.3)]',
59
  status: 'Active'
60
+ },
61
+ {
62
+ id: 'twitter',
63
+ name: 'X (Twitter)',
64
+ description: 'Publish tweets, images & videos',
65
+ icon: Twitter,
66
+ color: 'text-sky-500',
67
+ borderColor: 'group-hover:border-sky-500/50',
68
+ bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(14,165,233,0.3)]',
69
+ status: 'Active'
70
  }
71
  ];
72
 
 
76
  const [progressLogs, setProgressLogs] = useState([]);
77
  const [isComplete, setIsComplete] = useState(false);
78
  const [pdfResult, setPdfResult] = useState(null);
79
+ const [showGlobalUpload, setShowGlobalUpload] = useState(false);
80
 
81
  const handleDownloadReport = async () => {
82
  try {
 
143
 
144
  return (
145
  <div>
146
+ {showGlobalUpload && (
147
+ <GlobalUploadModal
148
+ onClose={() => setShowGlobalUpload(false)}
149
+ onSuccess={() => {
150
+ // success handled in modal for now
151
+ }}
152
+ />
153
+ )}
154
  <ProgressModal
155
  isOpen={showProgress}
156
  title={isComplete ? "Report Generated!" : "Generating Report..."}
 
214
  </div>
215
 
216
  <div className="flex gap-3">
217
+ <LoadingButton
218
+ onClick={() => setShowGlobalUpload(true)}
219
+ className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-xl font-medium transition-all shadow-lg shadow-primary/20"
220
+ >
221
+ <Upload className="w-4 h-4" />
222
+ Global Upload
223
+ </LoadingButton>
224
+
225
  <LoadingButton
226
  onClick={handleVerifyAppReview}
227
  loading={verifying}
social_media_publishers/oneup_client.py CHANGED
@@ -137,14 +137,26 @@ class OneUpClient:
137
  accounts = self.get_all_accounts()
138
  platform_type = platform_type.lower()
139
 
140
- # Normalize platform names from OneUp to internal
141
- # OneUp types: Facebook, X, Instagram, GBP, LinkedIn, TikTok, Threads, YouTube
142
-
143
  for acc in accounts:
144
  net_type = acc.get('social_network_type', '').lower()
145
 
146
- # Simple mapping/check
147
- if platform_type not in net_type and net_type not in platform_type:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  continue
149
 
150
  # Check identifier
 
137
  accounts = self.get_all_accounts()
138
  platform_type = platform_type.lower()
139
 
 
 
 
140
  for acc in accounts:
141
  net_type = acc.get('social_network_type', '').lower()
142
 
143
+ # Normalize platform names from OneUp to internal
144
+ # OneUp types: Facebook, X, Instagram, GBP, LinkedIn, TikTok, Threads, YouTube
145
+ # Internal types: facebook, twitter, instagram, tiktok, youtube, threads
146
+
147
+ # Platform name mapping
148
+ mapping = {
149
+ "twitter": "x",
150
+ "x": "twitter"
151
+ }
152
+
153
+ match = False
154
+ if platform_type in net_type or net_type in platform_type:
155
+ match = True
156
+ elif mapping.get(platform_type) == net_type:
157
+ match = True
158
+
159
+ if not match:
160
  continue
161
 
162
  # Check identifier
social_media_publishers/oneup_service.py CHANGED
@@ -49,22 +49,39 @@ class OneUpService:
49
  target_social_network_ids = []
50
  category_id = None
51
 
52
- if account_identifier:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  account = self.client.find_account(platform, account_identifier)
54
  if account:
55
  logger.info(f"OneUpService: Found account {account.get('social_network_name')} (ID: {account.get('social_network_id')})")
56
  target_social_network_ids.append(account.get('social_network_id'))
57
- # If the account has a category link (simulated in my client helper), use it.
58
- # However, the API *list_social_accounts* doesn't strictly return category_id.
59
- # The *list_category_accounts* does.
60
- # My client helper tries to inject it.
61
  category_id = account.get('category_id')
62
  else:
63
  logger.warning(f"OneUpService: Could not find specific account for '{account_identifier}'.")
64
- # Fallback? Maybe publish to ALL in a default category?
65
- # For now, let's fail if specific account requested but not found,
66
- # OR if user wanted specific account but we are unsure.
67
- # But if we can't find it, we can't get its ID.
68
  return {"error": f"OneUp account not found for {platform} user {account_identifier}"}
69
 
70
  # If we didn't get a category_id from the account lookup (e.g. if we used list_social_accounts and it didn't have it),
@@ -78,7 +95,9 @@ class OneUpService:
78
 
79
  # Just pick the first one for now
80
  category_id = categories[0]['id']
81
- logger.info(f"OneUpService: Using fallback category {categories[0]['category_name']} ({category_id})")
 
 
82
 
83
  # If no specific account found/requested, are we broadcasting?
84
  # The logic in app.py implies one account focus.
@@ -112,8 +131,11 @@ class OneUpService:
112
  return {"error": f"Failed to upload video for OneUp: {e}"}
113
 
114
  if not video_url:
 
115
  return {"error": "No video URL available for OneUp publishing."}
116
 
 
 
117
  # 3. Publish
118
  try:
119
  logger.info(f"OneUpService: Scheduling post to category {category_id}, accounts {target_social_network_ids}")
@@ -129,6 +151,7 @@ class OneUpService:
129
  if result.get('error'):
130
  return {"error": result.get('message')}
131
 
 
132
  return {
133
  "success": True,
134
  "message": result.get("message"),
 
49
  target_social_network_ids = []
50
  category_id = None
51
 
52
+ logger.info(f"πŸ“ STEP 1: Resolving OneUp account for {platform} (Identifier: {account_identifier})")
53
+ if account_identifier == 'all':
54
+ logger.info(f"OneUpService: Resolving ALL accounts for platform {platform}")
55
+ accounts = self.client.get_all_accounts()
56
+ # Handle platform mapping consistency
57
+ mapping = {"twitter": "x", "x": "twitter"}
58
+ p_lower = platform.lower()
59
+
60
+ for acc in accounts:
61
+ net_type = acc.get('social_network_type', '').lower()
62
+ match = False
63
+ if p_lower in net_type or net_type in p_lower:
64
+ match = True
65
+ elif mapping.get(p_lower) == net_type:
66
+ match = True
67
+
68
+ if match:
69
+ logger.info(f"OneUpService: Adding account {acc.get('social_network_name')} (ID: {acc.get('social_network_id')})")
70
+ target_social_network_ids.append(acc.get('social_network_id'))
71
+ if not category_id:
72
+ category_id = acc.get('category_id')
73
+
74
+ if not target_social_network_ids:
75
+ return {"error": f"No OneUp accounts found for platform {platform}"}
76
+
77
+ elif account_identifier:
78
  account = self.client.find_account(platform, account_identifier)
79
  if account:
80
  logger.info(f"OneUpService: Found account {account.get('social_network_name')} (ID: {account.get('social_network_id')})")
81
  target_social_network_ids.append(account.get('social_network_id'))
 
 
 
 
82
  category_id = account.get('category_id')
83
  else:
84
  logger.warning(f"OneUpService: Could not find specific account for '{account_identifier}'.")
 
 
 
 
85
  return {"error": f"OneUp account not found for {platform} user {account_identifier}"}
86
 
87
  # If we didn't get a category_id from the account lookup (e.g. if we used list_social_accounts and it didn't have it),
 
95
 
96
  # Just pick the first one for now
97
  category_id = categories[0]['id']
98
+ logger.info(f"βœ… OneUpService: Using fallback category {categories[0]['category_name']} ({category_id})")
99
+
100
+ logger.info(f"πŸ“ STEP 2: Preparing media for OneUp (Path: {content_path})")
101
 
102
  # If no specific account found/requested, are we broadcasting?
103
  # The logic in app.py implies one account focus.
 
131
  return {"error": f"Failed to upload video for OneUp: {e}"}
132
 
133
  if not video_url:
134
+ logger.error("❌ OneUpService: No video URL available after media prep.")
135
  return {"error": "No video URL available for OneUp publishing."}
136
 
137
+ logger.info(f"πŸ“ STEP 3: Scheduling post to OneUp (Category: {category_id}, Accounts: {target_social_network_ids})")
138
+
139
  # 3. Publish
140
  try:
141
  logger.info(f"OneUpService: Scheduling post to category {category_id}, accounts {target_social_network_ids}")
 
151
  if result.get('error'):
152
  return {"error": result.get('message')}
153
 
154
+ logger.info(f"πŸš€ OneUpService: Successfully scheduled post! Result: {result.get('message')}")
155
  return {
156
  "success": True,
157
  "message": result.get("message"),
social_media_publishers/publisher.py CHANGED
@@ -323,6 +323,8 @@ def run_official_publisher():
323
  account_id = get_config_value("INSTAGRAM_USERNAME")
324
  elif platform == 'facebook':
325
  account_id = get_config_value("FACEBOOK_USERNAME")
 
 
326
 
327
  if not account_id:
328
  logger.warning(f"⚠️ No account configured for {platform} (e.g. {platform.upper()}_USERNAME/EMAIL). Using default auth.")
 
323
  account_id = get_config_value("INSTAGRAM_USERNAME")
324
  elif platform == 'facebook':
325
  account_id = get_config_value("FACEBOOK_USERNAME")
326
+ elif platform == 'twitter':
327
+ account_id = get_config_value("X_USERNAME")
328
 
329
  if not account_id:
330
  logger.warning(f"⚠️ No account configured for {platform} (e.g. {platform.upper()}_USERNAME/EMAIL). Using default auth.")
social_media_publishers/requirements.txt CHANGED
@@ -25,4 +25,5 @@ cryptography
25
  git+https://github.com/jebin2/youtube_auto_pub.git
26
  aiohttp
27
  aiofiles
28
- itsdangerous
 
 
25
  git+https://github.com/jebin2/youtube_auto_pub.git
26
  aiohttp
27
  aiofiles
28
+ itsdangerous
29
+ xdk
social_media_publishers/twitter/__init__.py ADDED
File without changes
social_media_publishers/twitter/auth.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Dict, List, Optional, Any
3
+ from xdk.oauth2_auth import OAuth2PKCEAuth
4
+ from ..base import SocialAuthCreator
5
+ from src.config import get_config_value
6
+
7
+ class TwitterAuthCreator(SocialAuthCreator):
8
+ """Auth creator for X (Twitter) using xdk."""
9
+
10
+ def __init__(self):
11
+ super().__init__(platform_name="twitter")
12
+ self.scopes = [
13
+ "tweet.read",
14
+ "tweet.write",
15
+ "users.read",
16
+ "offline.access",
17
+ "media.write"
18
+ ]
19
+
20
+ def _get_auth_client(self, redirect_uri: str) -> OAuth2PKCEAuth:
21
+ client_id = get_config_value("x_client_id")
22
+ client_secret = get_config_value("x_client_secret")
23
+
24
+ if not client_id:
25
+ raise ValueError("x_client_id not found in configuration")
26
+
27
+ return OAuth2PKCEAuth(
28
+ client_id=client_id,
29
+ client_secret=client_secret,
30
+ redirect_uri=redirect_uri,
31
+ scope=self.scopes
32
+ )
33
+
34
+ def get_auth_url(self, redirect_uri: str) -> Dict[str, Any]:
35
+ """Generate X OAuth 2.0 auth URL with PKCE."""
36
+ auth = self._get_auth_client(redirect_uri)
37
+ url = auth.get_authorization_url()
38
+
39
+ return {
40
+ "url": url,
41
+ "state": "twitter_auth", # xdk manages state internally if desired, or we can pass it
42
+ "code_verifier": auth.get_code_verifier()
43
+ }
44
+
45
+ def handle_callback(self, request_url: str, state: str, redirect_uri: str, **kwargs) -> Dict[str, Any]:
46
+ """Handle the OAuth 2.0 callback."""
47
+ code_verifier = kwargs.get("code_verifier")
48
+ if not code_verifier:
49
+ return {"success": False, "error": "Missing code_verifier"}
50
+
51
+ auth = self._get_auth_client(redirect_uri)
52
+ auth.set_pkce_parameters(code_verifier=code_verifier)
53
+
54
+ token = auth.fetch_token(request_url)
55
+
56
+ if token:
57
+ # We might want to get the username/id to save it in a identifiable way
58
+ # But SocialAuthCreator's save_token_data usually expects a name
59
+ # For now, let's just save it.
60
+ # We can use get_me to find the username
61
+ from xdk import Client
62
+ client = Client(token=token)
63
+ me_resp = client.users.get_me()
64
+ username = getattr(me_resp.data, "username", "twitter_user") if me_resp.data else "twitter_user"
65
+
66
+ filename = f"{self.token_prefix}{username.replace('@', '_at_')}.json"
67
+ self.save_token_data(token, filename)
68
+
69
+ return {"success": True, "account": username}
70
+
71
+ return {"success": False, "error": "Failed to fetch token"}
social_media_publishers/twitter/publisher.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import glob
4
+ from typing import Dict, List, Optional, Any
5
+ from xdk import Client
6
+ from xdk.oauth2_auth import OAuth2PKCEAuth
7
+ from xdk.posts.models import CreateRequest, CreateRequestMedia
8
+ from xdk.media.models import UploadRequest, InitializeUploadRequest, AppendUploadRequest
9
+ from ..base import SocialPublisher, SocialAuthCreator
10
+ from src.config import get_config_value
11
+
12
+ class TwitterPublisher(SocialPublisher):
13
+ """Publisher for X (Twitter) using xdk."""
14
+
15
+ def __init__(self):
16
+ super().__init__()
17
+ self.platform_name = "twitter"
18
+ self.token_prefix = "twitter_token_"
19
+
20
+ def _get_token_manager(self):
21
+ # We can reuse the one from SocialAuthCreator if we want,
22
+ # but SocialPublisher doesn't inherit from SocialAuthCreator.
23
+ # We need a way to get the token.
24
+ # Standard pattern in this codebase seems to be checking 'encrypt' folder.
25
+ from .auth import TwitterAuthCreator
26
+ return TwitterAuthCreator()._get_token_manager()
27
+
28
+ def authenticate(self, account_id: Optional[str] = None) -> Optional[Client]:
29
+ """Authenticate with X using stored tokens."""
30
+ tm = self._get_token_manager()
31
+ if not tm:
32
+ print("❌ TokenManager not available")
33
+ return None
34
+
35
+ # If account_id is not provided, try to find the first connected account
36
+ if not account_id:
37
+ from .auth import TwitterAuthCreator
38
+ accounts = TwitterAuthCreator().list_connected_accounts()
39
+ if not accounts:
40
+ print("❌ No connected X accounts found")
41
+ return None
42
+ account_id = accounts[0]['name']
43
+
44
+ # Construct filename
45
+ filename = f"{self.token_prefix}{account_id.replace('@', '_at_')}.json"
46
+
47
+ # Ensure token is downloaded and decrypted
48
+ try:
49
+ tm.download_and_decrypt(filename)
50
+ except Exception as e:
51
+ print(f"⚠️ Failed to sync token from HF: {e}")
52
+
53
+ encrypt_path = "encrypt"
54
+ if hasattr(tm, 'config') and hasattr(tm.config, 'encrypt_path'):
55
+ encrypt_path = tm.config.encrypt_path
56
+
57
+ token_path = os.path.join(encrypt_path, filename)
58
+ if not os.path.exists(token_path):
59
+ print(f"❌ Token file not found: {token_path}")
60
+ return None
61
+
62
+ with open(token_path, 'r') as f:
63
+ token = json.load(f)
64
+
65
+ client_id = get_config_value("x_client_id")
66
+ client_secret = get_config_value("x_client_secret")
67
+
68
+ # Initialize xdk Client with token and OAuth2PKCEAuth for refresh support
69
+ auth = OAuth2PKCEAuth(
70
+ client_id=client_id,
71
+ client_secret=client_secret,
72
+ token=token,
73
+ redirect_uri="https://localhost/callback" # Dummy, matching what was used in auth
74
+ )
75
+
76
+ return Client(token=token, client_id=client_id, client_secret=client_secret)
77
+
78
+ def publish(self, content_path: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
79
+ """Publish a tweet with optional media."""
80
+ client = self.authenticate(metadata.get('account_id'))
81
+ if not client:
82
+ return {"success": False, "error": "Authentication failed"}
83
+
84
+ text = metadata.get('text') or metadata.get('title') or metadata.get('caption', "")
85
+
86
+ # Prepare media if path provided
87
+ media_id = None
88
+ if content_path:
89
+ local_path = self.prepare_content(content_path, metadata)
90
+ if local_path and os.path.exists(local_path):
91
+ print(f"πŸ“€ Uploading media to X: {local_path}")
92
+ try:
93
+ # In xdk, upload seems to expect media as a field.
94
+ # If it's a file path, we might need to read it if the SDK doesn't.
95
+ # Based on my research into xdk/media/client.py, it uses requests with json=...
96
+ # This suggests media might need to be base64 or a URL, or the SDK is incomplete for files.
97
+ # HOWEVER, many X libraries use chunked upload for videos.
98
+
99
+ # Let's try a simple upload first if it's an image.
100
+ # For videos, we might need chunked.
101
+ is_video = local_path.lower().endswith(('.mp4', '.mov', '.avi'))
102
+ category = "TWEET_VIDEO" if is_video else "TWEET_IMAGE"
103
+
104
+ if is_video:
105
+ media_id = self._upload_media_chunked(client, local_path, category)
106
+ else:
107
+ # For images, simple upload might work, but chunked is safer for large ones too
108
+ import base64
109
+ with open(local_path, "rb") as image_file:
110
+ encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
111
+
112
+ upload_req = UploadRequest(media=encoded_string, media_category=category.lower())
113
+ upload_res = client.media.upload(body=upload_req)
114
+
115
+ if upload_res and upload_res.data:
116
+ media_id = upload_res.data.id
117
+ print(f"βœ… Image uploaded successfully, ID: {media_id}")
118
+ except Exception as e:
119
+ print(f"❌ Error uploading media: {e}")
120
+ # If media upload fails, we might still try to post text if it's allowed
121
+ # but usually better to report failure if media was requested.
122
+
123
+ # Create Tweet
124
+ try:
125
+ media_info = None
126
+ if media_id:
127
+ media_info = CreateRequestMedia(media_ids=[media_id])
128
+
129
+ create_req = CreateRequest(text=text, media=media_info)
130
+ response = client.posts.create(body=create_req)
131
+
132
+ if response and response.data:
133
+ tweet_id = response.data.id
134
+ print(f"πŸš€ Tweet published! ID: {tweet_id}")
135
+ return {
136
+ "success": True,
137
+ "platform": "twitter",
138
+ "post_id": tweet_id,
139
+ "url": f"https://x.com/i/status/{tweet_id}"
140
+ }
141
+ else:
142
+ return {"success": False, "error": f"Failed to create tweet: {response}"}
143
+ except Exception as e:
144
+ msg = str(e)
145
+ if "402" in msg:
146
+ print("❌ X API Error: Payment Required (402).")
147
+ print("πŸ’‘ This usually means your X Developer App is on the 'Free' tier, which has very low limits (500 posts/month) or restricts media-posts.")
148
+ print("πŸ’‘ Check your Developer Portal: https://developer.x.com/en/portal/dashboard")
149
+ return {"success": False, "error": "X API Payment Required (Check Tier)"}
150
+
151
+ print(f"❌ Error creating tweet: {e}")
152
+ return {"success": False, "error": str(e)}
153
+
154
+ def _upload_media_chunked(self, client: Client, file_path: str, media_category: str) -> Optional[str]:
155
+ """Upload media in chunks (for videos)."""
156
+ import base64
157
+ import time
158
+ from xdk.media.models import InitializeUploadRequest, AppendUploadRequest
159
+
160
+ file_size = os.path.getsize(file_path)
161
+ # category should be UPPERCASE like TWEET_VIDEO per X docs
162
+
163
+ try:
164
+ # 1. INIT
165
+ print(f"🎬 Initializing chunked upload for {file_path} ({file_size} bytes)")
166
+ init_req = InitializeUploadRequest(
167
+ media_category=media_category,
168
+ media_type="video/mp4",
169
+ total_bytes=file_size
170
+ )
171
+ init_res = client.media.initialize_upload(body=init_req)
172
+ media_id = init_res.data.id
173
+ if not media_id:
174
+ print("❌ Failed to get media_id during INIT")
175
+ return None
176
+
177
+ # 2. APPEND
178
+ segment_index = 0
179
+ chunk_size = 1024 * 1024 # 1MB chunks
180
+ with open(file_path, 'rb') as f:
181
+ while True:
182
+ chunk = f.read(chunk_size)
183
+ if not chunk:
184
+ break
185
+
186
+ encoded_chunk = base64.b64encode(chunk).decode('utf-8')
187
+ append_req = AppendUploadRequest(
188
+ media=encoded_chunk,
189
+ segment_index=segment_index
190
+ )
191
+ client.media.append_upload(id=media_id, body=append_req)
192
+ segment_index += 1
193
+
194
+ print(f"πŸ“€ Uploaded {segment_index} chunks.")
195
+
196
+ # 3. FINALIZE
197
+ client.media.finalize_upload(id=media_id)
198
+
199
+ # 4. STATUS CHECK (wait for processing)
200
+ print(f"βŒ› Waiting for video processing for ID: {media_id}")
201
+ for i in range(12): # Wait up to 1 minute
202
+ status = client.media.get_upload_status(media_id=media_id)
203
+ # Correctly handle model attributes
204
+ if status and status.data and status.data.processing_info:
205
+ info = status.data.processing_info
206
+ state = getattr(info, "state", "pending")
207
+ if state == "succeeded":
208
+ print(f"βœ… Video processing complete!")
209
+ return media_id
210
+ if state == "failed":
211
+ print(f"❌ Video processing failed: {info}")
212
+ return None
213
+
214
+ check_after = getattr(info, "check_after_secs", 5) or 5
215
+ print(f"...processing ({state}, {getattr(info, 'progress_percent', 0)}%). Waiting {check_after}s...")
216
+ time.sleep(check_after)
217
+ else:
218
+ # If no processing info yet, just wait a bit
219
+ print(f"...waiting for status...")
220
+ time.sleep(5)
221
+
222
+ return media_id
223
+ except Exception as e:
224
+ print(f"❌ Error in chunked upload: {e}")
225
+ return None
226
+
227
+ def get_uploaded_videos(self, account_id: str, limit: int = 10, page_token: str = None, start_date: str = None, end_date: str = None) -> Dict[str, Any]:
228
+ """Get recent tweets (optionally filtered)."""
229
+ client = self.authenticate(account_id)
230
+ if not client:
231
+ return {"videos": []}
232
+
233
+ try:
234
+ # Get user ID
235
+ me_resp = client.users.get_me()
236
+ user_id = me_resp.data.id if me_resp and me_resp.data else None
237
+ if not user_id:
238
+ return {"videos": []}
239
+
240
+ # Get tweets
241
+ # xdk often has helper methods or we use client.users.get_posts
242
+ response = client.users.get_posts(id=user_id, max_results=limit, pagination_token=page_token)
243
+ tweets = response.data or []
244
+
245
+ return {
246
+ "videos": [
247
+ {
248
+ "id": getattr(t, "id", None),
249
+ "title": (getattr(t, "text", "")[:50] + "...") if getattr(t, "text", None) else "No text",
250
+ "published_at": getattr(t, "created_at", None),
251
+ "views": 0, # X API v2 requires specific fields for public metrics
252
+ "thumbnail": ""
253
+ } for t in tweets
254
+ ],
255
+ "next_page_token": response.meta.next_token if response and response.meta else None
256
+ }
257
+ except Exception as e:
258
+ print(f"❌ Error fetching tweets: {e}")
259
+ return {"videos": []}
260
+
261
+ def get_account_stats(self, account_id: str, start_date: str = None, end_date: str = None) -> Dict[str, Any]:
262
+ """Get account stats (followers count etc)."""
263
+ client = self.authenticate(account_id)
264
+ if not client:
265
+ return {}
266
+
267
+ try:
268
+ me_resp = client.users.get_me(user_fields=["public_metrics"])
269
+ me = me_resp.data
270
+ metrics = getattr(me, "public_metrics", None)
271
+
272
+ return {
273
+ "total_followers": getattr(metrics, "followers_count", 0) if metrics else 0,
274
+ "total_tweets": getattr(metrics, "tweet_count", 0) if metrics else 0,
275
+ "username": getattr(me, "username", "unknown") if me else "unknown"
276
+ }
277
+ except Exception as e:
278
+ print(f"❌ Error fetching account stats: {e}")
279
+ return {}
src/config.py CHANGED
@@ -164,6 +164,10 @@ def load_configuration(force_reload: bool = False) -> Dict[str, Any]:
164
  "encryption_key": os.getenv("ENCRYPTION_KEY"),
165
  "hf_token": os.getenv("HF_TOKEN"),
166
  "hf_space_url": os.getenv("HF_SPACE_URL"),
 
 
 
 
167
  }
168
 
169
  # On-screen CTA options
 
164
  "encryption_key": os.getenv("ENCRYPTION_KEY"),
165
  "hf_token": os.getenv("HF_TOKEN"),
166
  "hf_space_url": os.getenv("HF_SPACE_URL"),
167
+
168
+ # X (Twitter)
169
+ "x_client_id": os.getenv("X_CLIENT_ID"),
170
+ "x_client_secret": os.getenv("X_CLIENT_SECRET"),
171
  }
172
 
173
  # On-screen CTA options