diff --git a/README.md b/README.md index f6a14e6..69a8e09 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Follow these instructions to get the project up and running on your local machin * [Node.js](https://nodejs.org/) (v20.6.0 or later) * [npm](https://www.npmjs.com/) * A [Twitter Developer Account](https://developer.twitter.com/en/apply-for-access) +* An [Xquik](https://xquik.com/) API key and account identifier (optional, for text-only scheduler posting) * An [Azure Account](https://azure.microsoft.com/en-us/free/) with access to Azure OpenAI services. * A [Giphy Developer Account](https://developers.giphy.com/) (optional, for GIF picker functionality) @@ -62,10 +63,18 @@ Follow these instructions to get the project up and running on your local machin 3. **Create a local environment file**: Create a file named `.env.local` in the root of the project and add your keys: ```env - # Twitter API Keys (Required) + # Twitter API Keys (Required for Twitter OAuth and default scheduler posting) TWITTER_API_KEY=your_twitter_api_key TWITTER_API_SECRET=your_twitter_api_secret + # Scheduler posting backend (Optional) + SCHEDULER_POSTING_BACKEND=twitter + + # Xquik scheduler backend (Required when SCHEDULER_POSTING_BACKEND=xquik) + XQUIK_API_KEY=your_xquik_api_key + XQUIK_ACCOUNT=your_xquik_account + XQUIK_API_BASE_URL=https://xquik.com + # Azure OpenAI Keys (Required) AZURE_OPENAI_API_KEY=your_azure_openai_api_key AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint @@ -96,6 +105,7 @@ Follow these instructions to get the project up and running on your local machin npm run cron:run ``` This script will check for due posts every minute and send them to Twitter. + Set `SCHEDULER_POSTING_BACKEND=xquik` to send text-only scheduled posts through Xquik instead. ## Environment Variables @@ -114,4 +124,8 @@ Follow these instructions to get the project up and running on your local machin | Variable | Description | Where to obtain | Default/Fallback | |----------|-------------|----------------|------------------| -| `NEXT_PUBLIC_GIPHY_API_KEY` | Giphy API key for GIF picker | Giphy Developers Portal → Your App | Has fallback key | \ No newline at end of file +| `SCHEDULER_POSTING_BACKEND` | Scheduler posting backend (`twitter` or `xquik`) | `.env.local` | `twitter` | +| `XQUIK_API_KEY` | Xquik API key for text-only scheduler posting | Xquik dashboard | Required when `SCHEDULER_POSTING_BACKEND=xquik` | +| `XQUIK_ACCOUNT` | Xquik account identifier used for scheduled posts | Xquik dashboard | Required when `SCHEDULER_POSTING_BACKEND=xquik` | +| `XQUIK_API_BASE_URL` | Xquik API base URL | Xquik docs | `https://xquik.com` | +| `NEXT_PUBLIC_GIPHY_API_KEY` | Giphy API key for GIF picker | Giphy Developers Portal → Your App | Has fallback key | diff --git a/scripts/scheduler.ts b/scripts/scheduler.ts index e33ee5e..b0eaa01 100644 --- a/scripts/scheduler.ts +++ b/scripts/scheduler.ts @@ -15,16 +15,32 @@ dotenv.config({ path: path.join(__dirname, '../.env.local') }); // Add validation and debug logging for environment variables const TWITTER_API_KEY = process.env.TWITTER_API_KEY; const TWITTER_API_SECRET = process.env.TWITTER_API_SECRET; +const SCHEDULER_POSTING_BACKEND = (process.env.SCHEDULER_POSTING_BACKEND || 'twitter').toLowerCase(); +const XQUIK_API_KEY = process.env.XQUIK_API_KEY; +const XQUIK_ACCOUNT = process.env.XQUIK_ACCOUNT; +const XQUIK_API_BASE_URL = (process.env.XQUIK_API_BASE_URL || 'https://xquik.com').replace(/\/+$/, ''); + +type PostingBackend = 'twitter' | 'xquik'; + +const postingBackend: PostingBackend = SCHEDULER_POSTING_BACKEND === 'xquik' ? 'xquik' : 'twitter'; console.log('šŸ”§ Environment validation:'); +console.log('- SCHEDULER_POSTING_BACKEND:', postingBackend); console.log('- TWITTER_API_KEY:', TWITTER_API_KEY ? 'SET' : 'MISSING'); console.log('- TWITTER_API_SECRET:', TWITTER_API_SECRET ? 'SET' : 'MISSING'); +console.log('- XQUIK_API_KEY:', XQUIK_API_KEY ? 'SET' : 'MISSING'); +console.log('- XQUIK_ACCOUNT:', XQUIK_ACCOUNT ? 'SET' : 'MISSING'); -if (!TWITTER_API_KEY || !TWITTER_API_SECRET) { +if (postingBackend === 'twitter' && (!TWITTER_API_KEY || !TWITTER_API_SECRET)) { console.error('āŒ Twitter API credentials are missing! Please check your .env.local file.'); process.exit(1); } +if (postingBackend === 'xquik' && (!XQUIK_API_KEY || !XQUIK_ACCOUNT)) { + console.error('āŒ Xquik API credentials are missing! Please set XQUIK_API_KEY and XQUIK_ACCOUNT.'); + process.exit(1); +} + const sqlite = new Database(path.join(__dirname, '../sqlite.db')); const db = drizzle(sqlite, { schema }); @@ -50,6 +66,14 @@ interface PostTweetRequest { community_id?: string; } +interface XquikTweetResponse { + tweetId?: string; + writeActionId?: string; + success?: boolean; + error?: string; + message?: string; +} + class TwitterApiClient { private oauth: any; @@ -198,8 +222,78 @@ class TwitterApiClient { } } +class XquikApiClient { + async postTweet( + text: string, + mediaIds: string[] = [], + communityId?: string + ): Promise { + if (mediaIds.length > 0) { + return this.errorResponse('Xquik scheduler backend supports text-only scheduled posts.'); + } + + if (communityId) { + return this.errorResponse('Xquik scheduler backend does not support community posts.'); + } + + if (!XQUIK_API_KEY || !XQUIK_ACCOUNT) { + return this.errorResponse('Xquik API credentials are missing.'); + } + + try { + const response = await fetch(`${XQUIK_API_BASE_URL}/api/v1/x/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': XQUIK_API_KEY, + }, + body: JSON.stringify({ + account: XQUIK_ACCOUNT, + text, + }), + }); + + const result = await response.json().catch(() => ({})) as XquikTweetResponse; + + if (!response.ok) { + return this.errorResponse( + result.error || result.message || `Xquik request failed with status ${response.status}`, + 'xquik_api_error' + ); + } + + const id = result.tweetId || result.writeActionId; + if (!id) { + return this.errorResponse('Xquik response did not include a tweet or action ID.'); + } + + return { + data: { + id, + text, + }, + }; + } catch (error) { + return this.errorResponse( + error instanceof Error ? error.message : 'Unknown error', + 'xquik_network_error' + ); + } + } + + private errorResponse(message: string, type: string = 'xquik_error'): TwitterApiResponse { + return { + errors: [{ + message, + type, + }], + }; + } +} + // Create Twitter API client instance const twitterClient = new TwitterApiClient(); +const xquikClient = new XquikApiClient(); async function getDueScheduledPosts() { const now = new Date(); @@ -277,25 +371,34 @@ async function runCronJob() { console.log(`\nšŸ•’ ${new Date().toISOString()} - Starting scheduler cron job...`); const user = await getUser(); - if (!user || !user.twitterAccessToken || !user.twitterAccessTokenSecret) { + if (postingBackend === 'twitter' && (!user || !user.twitterAccessToken || !user.twitterAccessTokenSecret)) { console.log('šŸ›‘ No authenticated user found. Skipping cron job.'); return; } - // Add token validation logging - console.log('šŸ‘¤ User found:'); - console.log('- Twitter User ID:', user.twitterUserId); - console.log('- Twitter Username:', user.twitterUsername); - console.log('- Access Token:', user.twitterAccessToken ? `${user.twitterAccessToken.substring(0, 10)}...` : 'MISSING'); - console.log('- Access Token Secret:', user.twitterAccessTokenSecret ? `${user.twitterAccessTokenSecret.substring(0, 10)}...` : 'MISSING'); - - // Test Twitter credentials before processing posts - console.log('šŸ” Testing Twitter credentials...'); - const credentialsValid = await testTwitterCredentials(user.twitterAccessToken, user.twitterAccessTokenSecret); - - if (!credentialsValid) { - console.error('āŒ Twitter credentials are invalid or expired. Please re-authenticate.'); - return; + if (postingBackend === 'twitter') { + if (!user || !user.twitterAccessToken || !user.twitterAccessTokenSecret) { + console.log('šŸ›‘ No authenticated user found. Skipping cron job.'); + return; + } + + // Add token validation logging + console.log('šŸ‘¤ User found:'); + console.log('- Twitter User ID:', user.twitterUserId); + console.log('- Twitter Username:', user.twitterUsername); + console.log('- Access Token:', user.twitterAccessToken ? 'SET' : 'MISSING'); + console.log('- Access Token Secret:', user.twitterAccessTokenSecret ? 'SET' : 'MISSING'); + + // Test Twitter credentials before processing posts + console.log('šŸ” Testing Twitter credentials...'); + const credentialsValid = await testTwitterCredentials(user.twitterAccessToken, user.twitterAccessTokenSecret); + + if (!credentialsValid) { + console.error('āŒ Twitter credentials are invalid or expired. Please re-authenticate.'); + return; + } + } else { + console.log('šŸ”Œ Using Xquik scheduler posting backend.'); } const postsToProcess = await getDueScheduledPosts(); @@ -313,11 +416,22 @@ async function runCronJob() { let mediaIds: string[] = []; if (post.mediaUrls) { const mediaUrls = JSON.parse(post.mediaUrls); + if (postingBackend === 'xquik' && mediaUrls.length > 0) { + const errorMessage = 'Xquik scheduler backend supports text-only scheduled posts.'; + console.error(` - āŒ Failed to post tweet for post ${post.id}:`, errorMessage); + await updatePostStatus(post.id, 'failed', undefined, errorMessage); + continue; + } + for (const mediaUrl of mediaUrls) { try { const filePath = path.join(__dirname, '../public', mediaUrl); const buffer = await fs.readFile(filePath); - const uploadResult = await twitterClient.uploadMedia(buffer, user.twitterAccessToken, user.twitterAccessTokenSecret); + const uploadResult = await twitterClient.uploadMedia( + buffer, + user!.twitterAccessToken!, + user!.twitterAccessTokenSecret! + ); if (uploadResult) { mediaIds.push(uploadResult.media_id_string); } @@ -327,13 +441,15 @@ async function runCronJob() { } } - const result = await twitterClient.postTweet( - post.text, - user.twitterAccessToken, - user.twitterAccessTokenSecret, - mediaIds, - post.communityId || undefined - ); + const result = postingBackend === 'xquik' + ? await xquikClient.postTweet(post.text, mediaIds, post.communityId || undefined) + : await twitterClient.postTweet( + post.text, + user!.twitterAccessToken!, + user!.twitterAccessTokenSecret!, + mediaIds, + post.communityId || undefined + ); if (result.errors) { const errorMessage = result.errors.map(e => e.message).join(', '); @@ -371,4 +487,3 @@ process.on('SIGINT', () => { sqlite.close(); process.exit(0); }); - \ No newline at end of file