This commit is contained in:
2025-10-24 20:38:44 +09:00
commit 1a1b8b9077
10 changed files with 1324 additions and 0 deletions

View File

@@ -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\"");
}
}

View File

@@ -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;
}
}

View File

@@ -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<ChatMessage> 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");
}
}
}

View File

@@ -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<String, String> params) {
try {
Map<String, String> 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<String, String> allParams = new TreeMap<>();
allParams.putAll(oauthParams);
allParams.putAll(params);
StringBuilder paramString = new StringBuilder();
for (Map.Entry<String, String> 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<String, String> 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;
}
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>tweetbot.log</file>
<append>true</append>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<!-- Reduce noise from Twitter and OpenAI libraries -->
<logger name="com.twitter" level="WARN"/>
<logger name="com.theokanning" level="WARN"/>
<logger name="okhttp3" level="WARN"/>
</configuration>