Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 |
| `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 |
165 changes: 140 additions & 25 deletions scripts/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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;

Expand Down Expand Up @@ -198,8 +222,78 @@ class TwitterApiClient {
}
}

class XquikApiClient {
async postTweet(
text: string,
mediaIds: string[] = [],
communityId?: string
): Promise<TwitterApiResponse> {
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();
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand All @@ -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(', ');
Expand Down Expand Up @@ -371,4 +487,3 @@ process.on('SIGINT', () => {
sqlite.close();
process.exit(0);
});