use crate::config::{LlmConfig, TweetDefaults};
use crate::error::{HeraldError, Result};
use crate::events::Event;
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedTweet {
pub content: String,
pub length: usize,
#[serde(default)]
pub alternatives: Vec<String>,
#[serde(default)]
pub suggested_hashtags: Vec<String>,
pub event_id: String,
pub generated_at: chrono::DateTime<chrono::Utc>,
}
impl GeneratedTweet {
pub fn is_valid_length(&self, max: usize) -> bool {
self.length <= max
}
}
pub struct TweetGenerator {
config: LlmConfig,
defaults: TweetDefaults,
client: reqwest::Client,
}
impl TweetGenerator {
pub fn new(config: LlmConfig, defaults: TweetDefaults) -> Self {
Self {
config,
defaults,
client: reqwest::Client::new(),
}
}
pub async fn generate(&self, event: &Event) -> Result<GeneratedTweet> {
let prompt = self.build_prompt(event);
debug!("Generating tweet with prompt: {}", prompt);
let content = match self.config.provider.as_str() {
"anthropic" => self.generate_anthropic(&prompt).await?,
"openai" => self.generate_openai(&prompt).await?,
"ollama" => self.generate_ollama(&prompt).await?,
_ => return Err(HeraldError::Config(format!(
"Unknown LLM provider: {}",
self.config.provider
))),
};
let tweet = self.parse_response(&content, &event.id)?;
info!("Generated tweet: {} chars", tweet.length);
Ok(tweet)
}
pub async fn generate_variations(&self, event: &Event, count: usize) -> Result<Vec<GeneratedTweet>> {
let mut tweets = Vec::new();
for i in 0..count {
let mut modified_event = event.clone();
modified_event.context.tags.push(format!("variation_{}", i));
if let Ok(tweet) = self.generate(&modified_event).await {
tweets.push(tweet);
}
}
Ok(tweets)
}
fn build_prompt(&self, event: &Event) -> String {
let tone_instruction = match self.defaults.tone.as_str() {
"casual" => "Write in a casual, developer-friendly tone. Be conversational and relatable.",
"professional" => "Write in a professional but approachable tone. Be informative and credible.",
"hype" => "Write with excitement and energy! Use impactful words. Build anticipation.",
"technical" => "Write with technical precision. Focus on the technical merits and details.",
_ => "Write in a casual, engaging tone.",
};
let emoji_instruction = if self.defaults.emojis {
"Include 1-3 relevant emojis strategically placed."
} else {
"Do not use emojis."
};
let hashtag_instruction = if self.defaults.hashtags {
"Include 1-2 relevant hashtags at the end."
} else {
"Do not include hashtags in the tweet."
};
let link_note = if self.defaults.include_link {
if let Some(ref url) = event.url {
format!("\n\nInclude this link at the end: {}", url)
} else {
String::new()
}
} else {
String::new()
};
let highlights = if !event.context.highlights.is_empty() {
format!("\n\nKey highlights to mention:\n{}",
event.context.highlights.iter()
.map(|h| format!("- {}", h))
.collect::<Vec<_>>()
.join("\n"))
} else {
String::new()
};
format!(r#"Generate a viral tweet announcing the following:
Project: {}
Event: {:?}
Title: {}
{}
{}
{}
Requirements:
1. Maximum {} characters (including any links)
2. {}
3. {}
4. {}
5. Make it shareable and engaging
6. Focus on the value/benefit to developers
7. Avoid corporate jargon
8. Be authentic and direct
Output ONLY the tweet text, nothing else."#,
event.project,
event.event_type,
event.title,
event.description.as_deref().map(|d| format!("Description: {}", d)).unwrap_or_default(),
highlights,
link_note,
self.defaults.max_length,
tone_instruction,
emoji_instruction,
hashtag_instruction,
)
}
async fn generate_anthropic(&self, prompt: &str) -> Result<String> {
let url = self.config.base_url.as_deref()
.unwrap_or("https://api.anthropic.com/v1/messages");
let body = serde_json::json!({
"model": self.config.model,
"max_tokens": self.config.max_tokens,
"temperature": self.config.temperature,
"messages": [
{"role": "user", "content": prompt}
]
});
let response = self.client
.post(url)
.header("x-api-key", &self.config.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
return Err(HeraldError::Llm(format!("Anthropic API error: {}", error)));
}
let data: AnthropicResponse = response.json().await?;
data.content
.first()
.map(|c| c.text.clone())
.ok_or_else(|| HeraldError::Llm("Empty response from Anthropic".to_string()))
}
async fn generate_openai(&self, prompt: &str) -> Result<String> {
let url = self.config.base_url.as_deref()
.unwrap_or("https://api.openai.com/v1/chat/completions");
let body = serde_json::json!({
"model": self.config.model,
"max_tokens": self.config.max_tokens,
"temperature": self.config.temperature,
"messages": [
{"role": "user", "content": prompt}
]
});
let response = self.client
.post(url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
return Err(HeraldError::Llm(format!("OpenAI API error: {}", error)));
}
let data: OpenAIResponse = response.json().await?;
data.choices
.first()
.map(|c| c.message.content.clone())
.ok_or_else(|| HeraldError::Llm("Empty response from OpenAI".to_string()))
}
async fn generate_ollama(&self, prompt: &str) -> Result<String> {
let url = self.config.base_url.as_deref()
.unwrap_or("http://localhost:11434/api/generate");
let body = serde_json::json!({
"model": self.config.model,
"prompt": prompt,
"stream": false,
"options": {
"temperature": self.config.temperature
}
});
let response = self.client
.post(url)
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
return Err(HeraldError::Llm(format!("Ollama API error: {}", error)));
}
let data: OllamaResponse = response.json().await?;
Ok(data.response)
}
fn parse_response(&self, content: &str, event_id: &str) -> Result<GeneratedTweet> {
let content = content.trim().to_string();
let length = content.chars().count();
if length > self.defaults.max_length {
return Err(HeraldError::TweetTooLong {
max: self.defaults.max_length,
actual: length,
});
}
Ok(GeneratedTweet {
content,
length,
alternatives: Vec::new(),
suggested_hashtags: Vec::new(),
event_id: event_id.to_string(),
generated_at: chrono::Utc::now(),
})
}
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicContent>,
}
#[derive(Debug, Deserialize)]
struct AnthropicContent {
text: String,
}
#[derive(Debug, Deserialize)]
struct OpenAIResponse {
choices: Vec<OpenAIChoice>,
}
#[derive(Debug, Deserialize)]
struct OpenAIChoice {
message: OpenAIMessage,
}
#[derive(Debug, Deserialize)]
struct OpenAIMessage {
content: String,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
response: String,
}
pub struct TweetTemplates;
impl TweetTemplates {
pub fn crate_release(name: &str, version: &str, tagline: &str, url: &str) -> String {
format!(
"just shipped {} v{}\n\n{}\n\ncargo add {}\n\n{}",
name, version, tagline, name, url
)
}
pub fn github_release(name: &str, highlights: &[&str], url: &str) -> String {
let bullets = highlights.iter()
.map(|h| format!("โข {}", h))
.collect::<Vec<_>>()
.join("\n");
format!(
"๐ {} is out!\n\n{}\n\n{}",
name, bullets, url
)
}
pub fn feature(name: &str, feature: &str, benefit: &str) -> String {
format!(
"{} now supports {}\n\n{}\n\nmore in thread ๐งต",
name, feature, benefit
)
}
pub fn milestone(name: &str, metric: &str, value: &str) -> String {
format!(
"๐ {} just hit {} {}\n\nthank you to everyone who's been building with us",
name, value, metric
)
}
pub fn open_source(name: &str, description: &str, url: &str) -> String {
format!(
"just open-sourced {}\n\n{}\n\n{}",
name, description, url
)
}
pub fn thread_opener(topic: &str, count: usize) -> String {
format!(
"{}\n\n{} things you need to know ๐งต๐",
topic, count
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EventType;
#[test]
fn test_generated_tweet_length() {
let tweet = GeneratedTweet {
content: "Hello world!".to_string(),
length: 12,
alternatives: Vec::new(),
suggested_hashtags: Vec::new(),
event_id: "test".to_string(),
generated_at: chrono::Utc::now(),
};
assert!(tweet.is_valid_length(280));
assert!(tweet.is_valid_length(12));
assert!(!tweet.is_valid_length(11));
}
#[test]
fn test_crate_release_template() {
let tweet = TweetTemplates::crate_release(
"herald",
"0.1.0",
"automated viral tweets for developers",
"https://crates.io/crates/herald"
);
assert!(tweet.contains("herald"));
assert!(tweet.contains("0.1.0"));
assert!(tweet.contains("cargo add"));
}
#[test]
fn test_github_release_template() {
let tweet = TweetTemplates::github_release(
"Herald v0.1.0",
&["LLM-powered generation", "Twitter API v2", "Scheduling"],
"https://github.com/moltenlabs/herald"
);
assert!(tweet.contains("๐"));
assert!(tweet.contains("โข"));
}
#[test]
fn test_open_source_template() {
let tweet = TweetTemplates::open_source(
"herald",
"tweet automation for developers who ship",
"https://github.com/moltenlabs/herald"
);
assert!(tweet.contains("open-sourced"));
assert!(tweet.len() < 280);
}
#[test]
fn test_prompt_building() {
use crate::events::{Event, EventContext};
let generator = TweetGenerator::new(
LlmConfig::default(),
TweetDefaults::default(),
);
let event = Event {
id: "test".to_string(),
event_type: EventType::Release,
project: "herald".to_string(),
title: "Herald v0.1.0".to_string(),
description: Some("Tweet automation".to_string()),
version: Some("0.1.0".to_string()),
url: Some("https://example.com".to_string()),
timestamp: chrono::Utc::now(),
author: None,
context: EventContext::default(),
};
let prompt = generator.build_prompt(&event);
assert!(prompt.contains("herald"));
assert!(prompt.contains("Release"));
assert!(prompt.contains("280"));
}
}