commit 1a1b8b9077eb5968e49a1412d8ac4bf2f5a46609 Author: Botu SUN Date: Fri Oct 24 20:38:44 2025 +0900 init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a47149a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(tree:*)", + "Bash(mvn clean compile:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e94538 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# OpenAI API Configuration +OPENAI_API_KEY=your_openai_api_key_here + +# Twitter API Configuration (OAuth 2.0) +TWITTER_API_KEY=your_twitter_api_key_here +TWITTER_API_SECRET=your_twitter_api_secret_here +TWITTER_ACCESS_TOKEN=your_twitter_access_token_here +TWITTER_ACCESS_TOKEN_SECRET=your_twitter_access_token_secret_here +TWITTER_BEARER_TOKEN=your_twitter_bearer_token_here + +# Tweet Generation Configuration +TWEET_PROMPT=Write a short, engaging tweet about technology trends + +# Image Configuration (Optional) +# Path to a local image file to attach to every tweet (jpg, png, gif) +# Leave empty or comment out to post tweets without images +IMAGE_PATH=/path/to/your/image.jpg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9a60e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Environment variables (IMPORTANT: Never commit API keys!) +.env + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ +tweetbot.log + +# Compiled class files +*.class + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Java +hs_err_pid* +replay_pid* diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cef3a7 --- /dev/null +++ b/README.md @@ -0,0 +1,432 @@ +# TweetBot - AI-Powered Twitter Bot + +An automated Twitter bot that generates and posts tweets using OpenAI's ChatGPT API. This Java application allows you to create engaging social media content with custom prompts. + +## Features + +- Generate tweet content using ChatGPT (GPT-3.5-turbo) +- **Three operation modes:** + - Interactive mode with manual approval + - Auto-post mode for one-time tweets + - Scheduled mode (posts every 30 minutes) +- **Attach images to tweets** - Include a static image from local file system with every tweet +- Automatic tweet posting at regular intervals +- Customizable prompts for different content types +- Character limit validation (280 characters) +- Graceful error handling with automatic retry +- Comprehensive logging (console and file) +- Environment-based configuration +- Background service support + +## Prerequisites + +- Java 17 or higher +- Maven 3.6 or higher +- OpenAI API key +- Twitter Developer Account with API credentials + +## Setup Instructions + +### 1. Get OpenAI API Key + +1. Go to [OpenAI Platform](https://platform.openai.com/) +2. Sign up or log in to your account +3. Navigate to API keys section +4. Create a new API key and save it securely + +### 2. Get Twitter API Credentials + +1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard) +2. Create a new project and app (or use existing) +3. Navigate to your app's "Keys and tokens" section +4. Generate and save the following credentials: + - API Key (Consumer Key) + - API Secret (Consumer Secret) + - Access Token + - Access Token Secret + - Bearer Token + +**Important:** Ensure your Twitter app has **Read and Write** permissions: +- Go to your app settings +- Navigate to "User authentication settings" +- Set app permissions to "Read and Write" + +### 3. Configure the Application + +1. Copy the example environment file: + ```bash + cp .env.example .env + ``` + +2. Edit `.env` and add your API credentials: + ``` + OPENAI_API_KEY=sk-your-openai-api-key-here + TWITTER_API_KEY=your-twitter-api-key + TWITTER_API_SECRET=your-twitter-api-secret + TWITTER_ACCESS_TOKEN=your-twitter-access-token + TWITTER_ACCESS_TOKEN_SECRET=your-twitter-access-token-secret + TWITTER_BEARER_TOKEN=your-twitter-bearer-token + TWEET_PROMPT=Write a short, engaging tweet about technology trends + IMAGE_PATH=/path/to/your/image.jpg + ``` + +3. Customize the `TWEET_PROMPT` to match your desired content theme + +4. **(Optional) Configure Image Attachment:** + - Set `IMAGE_PATH` to the absolute path of an image file (jpg, png, gif) + - This image will be attached to every tweet + - Leave empty or comment out to post text-only tweets + - Example: `IMAGE_PATH=/home/user/images/logo.png` + - Supported formats: JPEG, PNG, GIF + - Maximum file size: 5MB (Twitter limit) + +### 5. Build the Project + +```bash +mvn clean package +``` + +This will: +- Download all dependencies +- Compile the source code +- Run tests (if any) +- Create an executable JAR file in the `target` directory + +## Usage + +TweetBot supports three modes of operation: + +### 1. Interactive Mode (Default) + +Generate a tweet and manually approve before posting: + +```bash +java -jar target/tweetbot-1.0.0.jar +``` + +Or using Maven: + +```bash +mvn exec:java -Dexec.mainClass="com.voidcode.tweetbot.TweetBot" +``` + +### 2. Scheduled Mode (Auto-post every 30 minutes) + +Run continuously and automatically post tweets every 30 minutes: + +```bash +java -jar target/tweetbot-1.0.0.jar --schedule +``` + +Or with a custom prompt: + +```bash +java -jar target/tweetbot-1.0.0.jar --schedule "Write daily tech tips" +``` + +The bot will: +- Post immediately when started +- Post a new tweet every 30 minutes +- Run continuously until stopped with Ctrl+C +- Automatically recover from errors and retry at the next interval + +**Note:** In scheduled mode, tweets are posted automatically without manual confirmation. + +### 3. Auto-Post Mode (One-time) + +Generate and post a single tweet automatically without confirmation: + +```bash +java -jar target/tweetbot-1.0.0.jar --auto +``` + +With custom prompt: + +```bash +java -jar target/tweetbot-1.0.0.jar --auto "Write a motivational quote about perseverance" +``` + +### Command-Line Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--schedule` | `-s` | Run in scheduled mode (posts every 30 minutes) | +| `--auto` | `-a` | Auto-post without confirmation (one-time) | +| `--help` | `-h` | Show help message | + +### Example Output + +**Interactive Mode:** +``` +========================================================== +Generated Tweet: +========================================================== +The future of AI is not about replacing humans, but +augmenting our capabilities. Together, we can achieve +what neither could alone. +========================================================== +Character count: 145/280 +========================================================== + +Do you want to post this tweet? (yes/no): yes + +========================================================== +SUCCESS! Tweet posted successfully! +Tweet ID: 1234567890123456789 +View at: https://twitter.com/user/status/1234567890123456789 +========================================================== +``` + +**Scheduled Mode:** +``` +========================================================== +TweetBot - SCHEDULED MODE +========================================================== +Tweet will be posted every 30 minutes +Press Ctrl+C to stop +========================================================== + +========================================================== +[2025-10-24 14:30:00] Tweet Posted Successfully! +========================================================== +AI is transforming how we work, learn, and create. +The future is collaborative intelligence. +========================================================== +Tweet ID: 1234567890123456789 +View at: https://twitter.com/user/status/1234567890123456789 +Next tweet in 30 minutes +========================================================== +``` + +## Project Structure + +``` +tweetbot/ +├── pom.xml # Maven configuration +├── .env # Environment variables (not in git) +├── .env.example # Example environment file +├── README.md # This file +└── src/ + └── main/ + ├── java/ + │ └── com/ + │ └── voidcode/ + │ └── tweetbot/ + │ ├── TweetBot.java # Main application + │ ├── config/ + │ │ └── Config.java # Configuration loader + │ └── service/ + │ ├── OpenAIService.java # ChatGPT integration + │ └── TwitterService.java # Twitter API integration + └── resources/ + └── logback.xml # Logging configuration +``` + +## Configuration Options + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `OPENAI_API_KEY` | Yes | Your OpenAI API key | +| `TWITTER_API_KEY` | Yes | Twitter API Key (Consumer Key) | +| `TWITTER_API_SECRET` | Yes | Twitter API Secret (Consumer Secret) | +| `TWITTER_ACCESS_TOKEN` | Yes | Twitter Access Token | +| `TWITTER_ACCESS_TOKEN_SECRET` | Yes | Twitter Access Token Secret | +| `TWITTER_BEARER_TOKEN` | Yes | Twitter Bearer Token | +| `TWEET_PROMPT` | No | Default prompt for tweet generation | +| `IMAGE_PATH` | No | Path to image file to attach to every tweet (jpg, png, gif) | + +### Customizing Tweet Generation + +You can customize the tweet generation by modifying the prompt. Here are some examples: + +```bash +# Technology news (interactive mode) +java -jar target/tweetbot-1.0.0.jar "Write a tweet about the latest AI breakthrough" + +# Motivational content (auto-post) +java -jar target/tweetbot-1.0.0.jar --auto "Write an inspiring quote about success" + +# Product updates (scheduled) +java -jar target/tweetbot-1.0.0.jar --schedule "Announce a new feature for a productivity app" + +# Educational content (scheduled) +java -jar target/tweetbot-1.0.0.jar --schedule "Share an interesting fact about space exploration" +``` + +### Attaching Images to Tweets + +Every tweet can include a static image from your local file system: + +**Setup:** +1. Place your image file anywhere on your system +2. Add the absolute path to `.env`: + ```bash + IMAGE_PATH=/home/user/images/brand-logo.png + ``` +3. Run the bot normally - the image will be automatically attached to every tweet + +**Image Requirements:** +- Supported formats: JPEG (.jpg, .jpeg), PNG (.png), GIF (.gif) +- Maximum file size: 5MB (Twitter API limit) +- Recommended dimensions: 1200x675 pixels for optimal display +- The image must exist at the specified path when the bot runs + +**Example Use Cases:** +- Brand logo or watermark on every tweet +- Product image for promotional content +- Infographic or visual content +- Profile or avatar image + +**To disable images:** +- Comment out or remove the `IMAGE_PATH` line in `.env` +- Or set it to an empty value: `IMAGE_PATH=` + +**Note:** If the image file is not found or fails to upload, the tweet will still be posted without the image, and an error will be logged. + +### Running as a Background Service + +To run the bot continuously in the background on Linux/Mac: + +```bash +# Run in background +nohup java -jar target/tweetbot-1.0.0.jar --schedule > tweetbot.out 2>&1 & + +# Check if it's running +ps aux | grep tweetbot + +# View output +tail -f tweetbot.out + +# Stop the bot +pkill -f tweetbot +``` + +**Using systemd (Linux):** + +Create a service file `/etc/systemd/system/tweetbot.service`: + +```ini +[Unit] +Description=TweetBot - Automated Twitter Posting +After=network.target + +[Service] +Type=simple +User=your-username +WorkingDirectory=/path/to/tweetbot +ExecStart=/usr/bin/java -jar /path/to/tweetbot/target/tweetbot-1.0.0.jar --schedule +Restart=always +RestartSec=60 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +Then enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable tweetbot +sudo systemctl start tweetbot +sudo systemctl status tweetbot + +# View logs +sudo journalctl -u tweetbot -f +``` + +## Logging + +The application creates logs in two places: +- **Console output**: Real-time logs with timestamps +- **File output**: `tweetbot.log` in the project directory + +Log levels: +- INFO: General application flow +- WARN: Warnings and non-critical issues +- ERROR: Errors and exceptions + +## Security Best Practices + +1. **Never commit `.env` file** - It contains sensitive API keys +2. **Use environment variables** - For production deployments +3. **Rotate API keys regularly** - Update keys periodically +4. **Limit API permissions** - Only grant necessary permissions +5. **Monitor API usage** - Check for unusual activity + +## Troubleshooting + +### "Missing required environment variable" + +Ensure your `.env` file exists and contains all required variables. + +### "Failed to verify Twitter credentials" + +1. Check that your API keys are correct +2. Verify your app has Read and Write permissions +3. Regenerate access tokens if needed + +### "Failed to generate tweet" + +1. Verify your OpenAI API key is valid +2. Check that you have sufficient API credits +3. Review the error message in logs + +### Twitter API Rate Limits + +Twitter API has rate limits: +- Tweet creation: 300 tweets per 3 hours (100 per hour average) +- The default 30-minute interval allows for 6 tweets per 3 hours, well within limits +- Monitor your usage to avoid hitting limits +- If you modify the schedule interval, ensure you stay within rate limits + +## Dependencies + +- **Twitter API Java SDK** (2.0.3) - Twitter API client +- **OpenAI GPT-3 Java** (0.18.2) - OpenAI API client +- **OkHttp** (4.12.0) - HTTP client +- **Jackson** (2.16.1) - JSON processing +- **SLF4J & Logback** (2.0.9) - Logging +- **Dotenv** (3.0.0) - Environment variable management + +## Contributing + +Feel free to fork this project and customize it for your needs. Some ideas: + +### Customizing the Schedule Interval + +To change the posting frequency from 30 minutes to another interval, edit `TweetBot.java`: + +```java +private static final int SCHEDULE_INTERVAL_MINUTES = 30; // Change this value +``` + +### Other Enhancement Ideas + +- Add support for images and media attachments +- Implement tweet threading for longer content +- Add sentiment analysis before posting +- Create multiple prompt templates with rotation +- Store tweet history in a database +- Add analytics and engagement tracking +- Support for multiple Twitter accounts +- Implement tweet approval queue/review system + +## License + +This project is open source and available for educational purposes. + +## Disclaimer + +Use this bot responsibly and in accordance with Twitter's Terms of Service and Automation Rules. Ensure you comply with rate limits and don't spam or post misleading content. + +## Support + +For issues or questions: +1. Check the troubleshooting section +2. Review application logs in `tweetbot.log` +3. Verify API credentials and permissions +4. Check Twitter and OpenAI API status pages diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8b84b5d --- /dev/null +++ b/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + com.voidcode.tweetbot + tweetbot + 1.0.0 + jar + + TweetBot + Auto-posting tweets with ChatGPT generated content + + + 17 + 17 + UTF-8 + + + + + + com.twitter + twitter-api-java-sdk + 2.0.3 + + + + + com.theokanning.openai-gpt3-java + service + 0.18.2 + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.16.1 + + + + + org.slf4j + slf4j-api + 2.0.9 + + + ch.qos.logback + logback-classic + 1.4.14 + + + + + io.github.cdimascio + dotenv-java + 3.0.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.voidcode.tweetbot.TweetBot + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + com.voidcode.tweetbot.TweetBot + + + + + + + + + diff --git a/src/main/java/com/voidcode/tweetbot/TweetBot.java b/src/main/java/com/voidcode/tweetbot/TweetBot.java new file mode 100644 index 0000000..ecc7b26 --- /dev/null +++ b/src/main/java/com/voidcode/tweetbot/TweetBot.java @@ -0,0 +1,253 @@ +package com.voidcode.tweetbot; + +import com.voidcode.tweetbot.config.Config; +import com.voidcode.tweetbot.service.OpenAIService; +import com.voidcode.tweetbot.service.TwitterService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class TweetBot { + private static final Logger logger = LoggerFactory.getLogger(TweetBot.class); + private static final int SCHEDULE_INTERVAL_MINUTES = 30; + private static OpenAIService openAIService; + private static TwitterService twitterService; + private static String tweetPrompt; + private static String imagePath; + private static volatile boolean isRunning = true; + + public static void main(String[] args) { + logger.info("Starting TweetBot..."); + + try { + // Parse command line arguments + boolean scheduleMode = false; + boolean autoPost = false; + String customPrompt = null; + + for (int i = 0; i < args.length; i++) { + if (args[i].equals("--schedule") || args[i].equals("-s")) { + scheduleMode = true; + autoPost = true; // Auto-post in schedule mode + } else if (args[i].equals("--auto") || args[i].equals("-a")) { + autoPost = true; + } else if (args[i].equals("--help") || args[i].equals("-h")) { + printUsage(); + System.exit(0); + } else { + // Everything else is treated as custom prompt + customPrompt = String.join(" ", java.util.Arrays.copyOfRange(args, i, args.length)); + break; + } + } + + // Load configuration + logger.info("Loading configuration..."); + String openAIApiKey = Config.getOpenAIApiKey(); + String twitterApiKey = Config.getTwitterApiKey(); + String twitterApiSecret = Config.getTwitterApiSecret(); + String twitterAccessToken = Config.getTwitterAccessToken(); + String twitterAccessTokenSecret = Config.getTwitterAccessTokenSecret(); + tweetPrompt = customPrompt != null ? customPrompt : Config.getTweetPrompt(); + imagePath = Config.getImagePath(); + + if (imagePath != null) { + logger.info("Image path configured: {}", imagePath); + } else { + logger.info("No image path configured, tweets will be text-only"); + } + + logger.info("Configuration loaded successfully"); + + // Initialize services + logger.info("Initializing services..."); + openAIService = new OpenAIService(openAIApiKey); + twitterService = new TwitterService( + twitterApiKey, + twitterApiSecret, + twitterAccessToken, + twitterAccessTokenSecret + ); + + // Verify Twitter credentials + logger.info("Verifying Twitter credentials..."); + if (!twitterService.verifyCredentials()) { + logger.error("Failed to verify Twitter credentials. Please check your API keys."); + System.exit(1); + } + + if (scheduleMode) { + runScheduledMode(); + } else { + runOnceMode(autoPost); + } + + } catch (IllegalStateException e) { + logger.error("Configuration error: {}", e.getMessage()); + System.err.println("\nConfiguration Error: " + e.getMessage()); + System.err.println("Please check your .env file and ensure all required variables are set."); + System.err.println("See .env.example for reference."); + System.exit(1); + } catch (Exception e) { + logger.error("An error occurred", e); + System.err.println("\nError: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + private static void runScheduledMode() { + logger.info("Running in SCHEDULED mode - posting every {} minutes", SCHEDULE_INTERVAL_MINUTES); + System.out.println("\n" + "=".repeat(60)); + System.out.println("TweetBot - SCHEDULED MODE"); + System.out.println("=".repeat(60)); + System.out.println("Tweet will be posted every " + SCHEDULE_INTERVAL_MINUTES + " minutes"); + System.out.println("Press Ctrl+C to stop"); + System.out.println("=".repeat(60) + "\n"); + + // Add shutdown hook to gracefully stop + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("Shutdown signal received, stopping TweetBot..."); + isRunning = false; + if (openAIService != null) { + openAIService.shutdown(); + } + logger.info("TweetBot stopped gracefully"); + })); + + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + // Post immediately on startup + postTweetAutomatically(); + + // Schedule to run every 30 minutes + scheduler.scheduleAtFixedRate(() -> { + if (isRunning) { + postTweetAutomatically(); + } + }, SCHEDULE_INTERVAL_MINUTES, SCHEDULE_INTERVAL_MINUTES, TimeUnit.MINUTES); + + // Keep the application running + try { + while (isRunning) { + Thread.sleep(1000); + } + } catch (InterruptedException e) { + logger.info("Main thread interrupted, shutting down..."); + } finally { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + } + } + } + + private static void runOnceMode(boolean autoPost) { + logger.info("Running in ONE-TIME mode"); + + try { + // Generate tweet content + logger.info("Generating tweet content..."); + String tweetContent = openAIService.generateTweet(tweetPrompt); + + // Display generated content + System.out.println("\n" + "=".repeat(60)); + System.out.println("Generated Tweet:"); + System.out.println("=".repeat(60)); + System.out.println(tweetContent); + System.out.println("=".repeat(60)); + System.out.println("Character count: " + tweetContent.length() + "/280"); + System.out.println("=".repeat(60) + "\n"); + + boolean shouldPost = autoPost; + + if (!autoPost) { + // Ask for confirmation + System.out.print("Do you want to post this tweet? (yes/no): "); + java.util.Scanner scanner = new java.util.Scanner(System.in); + String confirmation = scanner.nextLine().trim().toLowerCase(); + shouldPost = confirmation.equals("yes") || confirmation.equals("y"); + scanner.close(); + } + + if (shouldPost) { + // Post tweet + logger.info("Posting tweet to Twitter..."); + String tweetId = twitterService.postTweet(tweetContent, imagePath); + + System.out.println("\n" + "=".repeat(60)); + System.out.println("SUCCESS! Tweet posted successfully!"); + System.out.println("Tweet ID: " + tweetId); + System.out.println("View at: https://twitter.com/user/status/" + tweetId); + System.out.println("=".repeat(60) + "\n"); + } else { + logger.info("Tweet posting cancelled by user"); + System.out.println("\nTweet posting cancelled."); + } + + } finally { + // Cleanup + if (openAIService != null) { + openAIService.shutdown(); + } + logger.info("TweetBot finished"); + } + } + + private static void postTweetAutomatically() { + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + logger.info("[{}] Starting scheduled tweet posting...", timestamp); + + // Generate tweet content + String tweetContent = openAIService.generateTweet(tweetPrompt); + logger.info("Generated tweet: {}", tweetContent); + + // Post tweet + String tweetId = twitterService.postTweet(tweetContent, imagePath); + + System.out.println("\n" + "=".repeat(60)); + System.out.println("[" + timestamp + "] Tweet Posted Successfully!"); + System.out.println("=".repeat(60)); + System.out.println(tweetContent); + System.out.println("=".repeat(60)); + System.out.println("Tweet ID: " + tweetId); + System.out.println("View at: https://twitter.com/user/status/" + tweetId); + System.out.println("Next tweet in " + SCHEDULE_INTERVAL_MINUTES + " minutes"); + System.out.println("=".repeat(60) + "\n"); + + } catch (Exception e) { + logger.error("Error posting scheduled tweet", e); + System.err.println("\n[ERROR] Failed to post tweet: " + e.getMessage()); + System.err.println("Will retry at next scheduled time.\n"); + } + } + + private static void printUsage() { + System.out.println("TweetBot - Automated Twitter posting with ChatGPT"); + System.out.println("\nUsage:"); + System.out.println(" java -jar tweetbot.jar [OPTIONS] [CUSTOM_PROMPT]"); + System.out.println("\nOptions:"); + System.out.println(" -s, --schedule Run in scheduled mode (posts every 30 minutes)"); + System.out.println(" -a, --auto Auto-post without confirmation (one-time mode)"); + System.out.println(" -h, --help Show this help message"); + System.out.println("\nExamples:"); + System.out.println(" # Interactive mode with confirmation"); + System.out.println(" java -jar tweetbot.jar"); + System.out.println("\n # Auto-post once with custom prompt"); + System.out.println(" java -jar tweetbot.jar --auto \"Write a tweet about AI\""); + System.out.println("\n # Scheduled mode (posts every 30 minutes)"); + System.out.println(" java -jar tweetbot.jar --schedule"); + System.out.println("\n # Scheduled mode with custom prompt"); + System.out.println(" java -jar tweetbot.jar --schedule \"Daily tech tips\""); + } +} diff --git a/src/main/java/com/voidcode/tweetbot/config/Config.java b/src/main/java/com/voidcode/tweetbot/config/Config.java new file mode 100644 index 0000000..bba5060 --- /dev/null +++ b/src/main/java/com/voidcode/tweetbot/config/Config.java @@ -0,0 +1,59 @@ +package com.voidcode.tweetbot.config; + +import io.github.cdimascio.dotenv.Dotenv; + +public class Config { + private static final Dotenv dotenv = Dotenv.configure() + .directory(".") + .ignoreIfMissing() + .load(); + + // OpenAI Configuration + public static String getOpenAIApiKey() { + return getEnvOrThrow("OPENAI_API_KEY"); + } + + // Twitter Configuration + public static String getTwitterApiKey() { + return getEnvOrThrow("TWITTER_API_KEY"); + } + + public static String getTwitterApiSecret() { + return getEnvOrThrow("TWITTER_API_SECRET"); + } + + public static String getTwitterAccessToken() { + return getEnvOrThrow("TWITTER_ACCESS_TOKEN"); + } + + public static String getTwitterAccessTokenSecret() { + return getEnvOrThrow("TWITTER_ACCESS_TOKEN_SECRET"); + } + + public static String getTwitterBearerToken() { + return getEnvOrThrow("TWITTER_BEARER_TOKEN"); + } + + // Tweet Generation Configuration + public static String getTweetPrompt() { + return dotenv.get("TWEET_PROMPT", "Write a short, engaging tweet about technology"); + } + + // Image Configuration (Optional) + public static String getImagePath() { + String path = dotenv.get("IMAGE_PATH"); + // Return null if empty or not set, allowing tweets without images + if (path == null || path.isEmpty() || path.equals("/path/to/your/image.jpg")) { + return null; + } + return path; + } + + private static String getEnvOrThrow(String key) { + String value = dotenv.get(key); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("Missing required environment variable: " + key); + } + return value; + } +} diff --git a/src/main/java/com/voidcode/tweetbot/service/OpenAIService.java b/src/main/java/com/voidcode/tweetbot/service/OpenAIService.java new file mode 100644 index 0000000..cf93e2d --- /dev/null +++ b/src/main/java/com/voidcode/tweetbot/service/OpenAIService.java @@ -0,0 +1,91 @@ +package com.voidcode.tweetbot.service; + +import com.theokanning.openai.completion.chat.ChatCompletionRequest; +import com.theokanning.openai.completion.chat.ChatCompletionResult; +import com.theokanning.openai.completion.chat.ChatMessage; +import com.theokanning.openai.completion.chat.ChatMessageRole; +import com.theokanning.openai.service.OpenAiService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +public class OpenAIService { + private static final Logger logger = LoggerFactory.getLogger(OpenAIService.class); + private final OpenAiService openAiService; + private static final int MAX_TWEET_LENGTH = 280; + + public OpenAIService(String apiKey) { + this.openAiService = new OpenAiService(apiKey, Duration.ofSeconds(60)); + logger.info("OpenAI service initialized"); + } + + /** + * Generate tweet content using ChatGPT + * @param prompt The prompt to generate the tweet + * @return Generated tweet text + */ + public String generateTweet(String prompt) { + try { + logger.info("Generating tweet with prompt: {}", prompt); + + // Create the system message to set context + ChatMessage systemMessage = new ChatMessage( + ChatMessageRole.SYSTEM.value(), + "You are a creative social media content creator. Generate engaging tweets that are concise, " + + "interesting, and within Twitter's character limit. Do not include hashtags unless specifically " + + "requested. Keep it under 280 characters." + ); + + // Create the user message with the prompt + ChatMessage userMessage = new ChatMessage( + ChatMessageRole.USER.value(), + prompt + " Keep it under " + MAX_TWEET_LENGTH + " characters." + ); + + List messages = new ArrayList<>(); + messages.add(systemMessage); + messages.add(userMessage); + + // Create the chat completion request + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model("gpt-3.5-turbo") + .messages(messages) + .temperature(0.8) + .maxTokens(100) + .build(); + + // Get the response + ChatCompletionResult result = openAiService.createChatCompletion(request); + String generatedText = result.getChoices().get(0).getMessage().getContent().trim(); + + // Remove quotes if the AI wrapped the tweet in quotes + generatedText = generatedText.replaceAll("^[\"']|[\"']$", ""); + + // Ensure it's within Twitter's character limit + if (generatedText.length() > MAX_TWEET_LENGTH) { + logger.warn("Generated tweet exceeds {} characters, truncating", MAX_TWEET_LENGTH); + generatedText = generatedText.substring(0, MAX_TWEET_LENGTH - 3) + "..."; + } + + logger.info("Generated tweet: {}", generatedText); + return generatedText; + + } catch (Exception e) { + logger.error("Error generating tweet", e); + throw new RuntimeException("Failed to generate tweet: " + e.getMessage(), e); + } + } + + /** + * Shutdown the OpenAI service + */ + public void shutdown() { + if (openAiService != null) { + openAiService.shutdownExecutor(); + logger.info("OpenAI service shut down"); + } + } +} diff --git a/src/main/java/com/voidcode/tweetbot/service/TwitterService.java b/src/main/java/com/voidcode/tweetbot/service/TwitterService.java new file mode 100644 index 0000000..dddd43e --- /dev/null +++ b/src/main/java/com/voidcode/tweetbot/service/TwitterService.java @@ -0,0 +1,276 @@ +package com.voidcode.tweetbot.service; + +import com.twitter.clientlib.TwitterCredentialsOAuth2; +import com.twitter.clientlib.api.TwitterApi; +import com.twitter.clientlib.ApiException; +import com.twitter.clientlib.model.TweetCreateRequest; +import com.twitter.clientlib.model.TweetCreateRequestMedia; +import com.twitter.clientlib.model.TweetCreateResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +public class TwitterService { + private static final Logger logger = LoggerFactory.getLogger(TwitterService.class); + private final TwitterApi apiInstance; + private final String apiKey; + private final String apiSecret; + private final String accessToken; + private final String accessTokenSecret; + private static final String MEDIA_UPLOAD_URL = "https://upload.twitter.com/1.1/media/upload.json"; + + public TwitterService(String apiKey, String apiSecret, String accessToken, String accessTokenSecret) { + try { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.accessToken = accessToken; + this.accessTokenSecret = accessTokenSecret; + + // Initialize Twitter API with OAuth 1.0a credentials + TwitterCredentialsOAuth2 credentials = new TwitterCredentialsOAuth2( + apiKey, + apiSecret, + accessToken, + accessTokenSecret + ); + + apiInstance = new TwitterApi(credentials); + logger.info("Twitter service initialized successfully"); + } catch (Exception e) { + logger.error("Failed to initialize Twitter service", e); + throw new RuntimeException("Failed to initialize Twitter service: " + e.getMessage(), e); + } + } + + /** + * Post a tweet to Twitter without media + * @param tweetText The text content of the tweet + * @return The ID of the posted tweet + */ + public String postTweet(String tweetText) { + return postTweet(tweetText, null); + } + + /** + * Post a tweet to Twitter with optional image + * @param tweetText The text content of the tweet + * @param imagePath Path to the image file (null for no image) + * @return The ID of the posted tweet + */ + public String postTweet(String tweetText, String imagePath) { + try { + logger.info("Posting tweet: {}", tweetText); + + // Create the tweet request + TweetCreateRequest tweetCreateRequest = new TweetCreateRequest(); + tweetCreateRequest.setText(tweetText); + + // Upload media if image path is provided + if (imagePath != null && !imagePath.isEmpty()) { + File imageFile = new File(imagePath); + if (!imageFile.exists()) { + logger.warn("Image file not found: {}. Posting tweet without image.", imagePath); + } else { + try { + String mediaId = uploadMedia(imageFile); + logger.info("Media uploaded successfully. Media ID: {}", mediaId); + + // Attach media to tweet + TweetCreateRequestMedia media = new TweetCreateRequestMedia(); + media.addMediaIdsItem(mediaId); + tweetCreateRequest.setMedia(media); + } catch (Exception e) { + logger.error("Failed to upload media, posting tweet without image", e); + } + } + } + + // Post the tweet + TweetCreateResponse result = apiInstance.tweets().createTweet(tweetCreateRequest).execute(); + + String tweetId = result.getData().getId(); + logger.info("Tweet posted successfully. Tweet ID: {}", tweetId); + logger.info("View tweet at: https://twitter.com/user/status/{}", tweetId); + + return tweetId; + + } catch (ApiException e) { + logger.error("Error posting tweet. Status code: {}, Response: {}", + e.getCode(), e.getResponseBody(), e); + throw new RuntimeException("Failed to post tweet: " + e.getMessage(), e); + } catch (Exception e) { + logger.error("Unexpected error posting tweet", e); + throw new RuntimeException("Failed to post tweet: " + e.getMessage(), e); + } + } + + /** + * Upload media to Twitter using v1.1 API + * @param imageFile The image file to upload + * @return The media ID + */ + private String uploadMedia(File imageFile) throws IOException { + logger.info("Uploading media file: {}", imageFile.getAbsolutePath()); + + // Read file content + byte[] fileContent = Files.readAllBytes(imageFile.toPath()); + String base64Content = Base64.getEncoder().encodeToString(fileContent); + + // Create multipart request body + String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + StringBuilder bodyBuilder = new StringBuilder(); + + bodyBuilder.append("--").append(boundary).append("\r\n"); + bodyBuilder.append("Content-Disposition: form-data; name=\"media_data\"\r\n\r\n"); + bodyBuilder.append(base64Content).append("\r\n"); + bodyBuilder.append("--").append(boundary).append("--\r\n"); + + byte[] body = bodyBuilder.toString().getBytes(StandardCharsets.UTF_8); + + // Create OAuth header + String authHeader = generateOAuthHeader("POST", MEDIA_UPLOAD_URL, new TreeMap<>()); + + // Make HTTP request + HttpURLConnection connection = (HttpURLConnection) new URL(MEDIA_UPLOAD_URL).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Authorization", authHeader); + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + connection.setRequestProperty("Content-Length", String.valueOf(body.length)); + + // Send request + try (OutputStream os = connection.getOutputStream()) { + os.write(body); + os.flush(); + } + + // Read response + int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + throw new IOException("Media upload failed with response code: " + responseCode); + } + + Scanner scanner = new Scanner(connection.getInputStream()); + StringBuilder response = new StringBuilder(); + while (scanner.hasNextLine()) { + response.append(scanner.nextLine()); + } + scanner.close(); + + // Parse media_id from response JSON + String responseStr = response.toString(); + String mediaId = extractMediaId(responseStr); + + if (mediaId == null) { + throw new IOException("Failed to extract media_id from response"); + } + + return mediaId; + } + + /** + * Extract media_id from JSON response + */ + private String extractMediaId(String jsonResponse) { + // Simple JSON parsing to extract media_id_string + int index = jsonResponse.indexOf("\"media_id_string\""); + if (index == -1) return null; + + int startQuote = jsonResponse.indexOf("\"", index + 18); + if (startQuote == -1) return null; + + int endQuote = jsonResponse.indexOf("\"", startQuote + 1); + if (endQuote == -1) return null; + + return jsonResponse.substring(startQuote + 1, endQuote); + } + + /** + * Generate OAuth 1.0a authorization header + */ + private String generateOAuthHeader(String method, String url, Map params) { + try { + Map oauthParams = new TreeMap<>(); + oauthParams.put("oauth_consumer_key", apiKey); + oauthParams.put("oauth_token", accessToken); + oauthParams.put("oauth_signature_method", "HMAC-SHA1"); + oauthParams.put("oauth_timestamp", String.valueOf(System.currentTimeMillis() / 1000)); + oauthParams.put("oauth_nonce", UUID.randomUUID().toString().replaceAll("-", "")); + oauthParams.put("oauth_version", "1.0"); + + // Create signature + Map allParams = new TreeMap<>(); + allParams.putAll(oauthParams); + allParams.putAll(params); + + StringBuilder paramString = new StringBuilder(); + for (Map.Entry entry : allParams.entrySet()) { + if (paramString.length() > 0) paramString.append("&"); + paramString.append(urlEncode(entry.getKey())).append("=").append(urlEncode(entry.getValue())); + } + + String signatureBase = method + "&" + urlEncode(url) + "&" + urlEncode(paramString.toString()); + String signingKey = urlEncode(apiSecret) + "&" + urlEncode(accessTokenSecret); + + Mac mac = Mac.getInstance("HmacSHA1"); + SecretKeySpec secret = new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA1"); + mac.init(secret); + byte[] digest = mac.doFinal(signatureBase.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder().encodeToString(digest); + + oauthParams.put("oauth_signature", signature); + + // Build header + StringBuilder header = new StringBuilder("OAuth "); + for (Map.Entry entry : oauthParams.entrySet()) { + if (header.length() > 6) header.append(", "); + header.append(urlEncode(entry.getKey())).append("=\"").append(urlEncode(entry.getValue())).append("\""); + } + + return header.toString(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate OAuth header", e); + } + } + + private String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } catch (Exception e) { + throw new RuntimeException("Failed to URL encode", e); + } + } + + /** + * Verify Twitter credentials by getting authenticated user info + * @return true if credentials are valid + */ + public boolean verifyCredentials() { + try { + logger.info("Verifying Twitter credentials..."); + // Try to get the authenticated user's information + apiInstance.users().findMyUser().execute(); + logger.info("Twitter credentials verified successfully"); + return true; + } catch (Exception e) { + logger.error("Failed to verify Twitter credentials", e); + return false; + } + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..4066992 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,26 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + tweetbot.log + true + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + +