- Phase 1 Critical: All 115 .unwrap() verified in test code only - Phase 1 Critical: All runtime .expect() converted to proper error handling - Phase 2 H1: Antivirus commands now use SafeCommand (added which/where to whitelist) - Phase 2 H2: db_api.rs error responses use log_and_sanitize() - Phase 2 H5: Removed duplicate sanitize_identifier (re-exports from sql_guard) 32 files modified for security hardening. Moon deployment criteria: 10/10 met
487 lines
16 KiB
Rust
487 lines
16 KiB
Rust
/*****************************************************************************\
|
|
| █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® |
|
|
| ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
|
|
| ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ |
|
|
| ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ |
|
|
| █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ |
|
|
| |
|
|
| General Bots Copyright (c) pragmatismo.com.br. All rights reserved. |
|
|
| Licensed under the AGPL-3.0. |
|
|
| |
|
|
| According to our dual licensing model, this program can be used either |
|
|
| under the terms of the GNU Affero General Public License, version 3, |
|
|
| or under a proprietary license. |
|
|
| |
|
|
| The texts of the GNU Affero General Public License with an additional |
|
|
| permission and of our proprietary license can be found at and |
|
|
| in the LICENSE file you have received along with this program. |
|
|
| |
|
|
| This program is distributed in the hope that it will be useful, |
|
|
| but WITHOUT ANY WARRANTY, without even the implied warranty of |
|
|
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
|
| GNU Affero General Public License for more details. |
|
|
| |
|
|
| "General Bots" is a registered trademark of pragmatismo.com.br. |
|
|
| The licensing of the program under the AGPLv3 does not imply a |
|
|
| trademark license. Therefore any rights, title and interest in |
|
|
| our trademarks remain entirely with us. |
|
|
| |
|
|
\*****************************************************************************/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
use crate::basic::runtime::{BasicRuntime, BasicValue};
|
|
use crate::llm::LLMProvider;
|
|
use anyhow::{anyhow, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub enum CardStyle {
|
|
#[default]
|
|
Modern,
|
|
Minimal,
|
|
Vibrant,
|
|
Dark,
|
|
Light,
|
|
Gradient,
|
|
Polaroid,
|
|
Magazine,
|
|
Story,
|
|
Carousel,
|
|
}
|
|
|
|
impl From<&str> for CardStyle {
|
|
fn from(s: &str) -> Self {
|
|
match s.to_lowercase().as_str() {
|
|
"minimal" => CardStyle::Minimal,
|
|
"vibrant" => CardStyle::Vibrant,
|
|
"dark" => CardStyle::Dark,
|
|
"light" => CardStyle::Light,
|
|
"gradient" => CardStyle::Gradient,
|
|
"polaroid" => CardStyle::Polaroid,
|
|
"magazine" => CardStyle::Magazine,
|
|
"story" => CardStyle::Story,
|
|
"carousel" => CardStyle::Carousel,
|
|
_ => CardStyle::Modern,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct CardDimensions {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
impl CardDimensions {
|
|
pub const INSTAGRAM_SQUARE: Self = Self {
|
|
width: 1080,
|
|
height: 1080,
|
|
};
|
|
pub const INSTAGRAM_PORTRAIT: Self = Self {
|
|
width: 1080,
|
|
height: 1350,
|
|
};
|
|
pub const INSTAGRAM_STORY: Self = Self {
|
|
width: 1080,
|
|
height: 1920,
|
|
};
|
|
pub const INSTAGRAM_LANDSCAPE: Self = Self {
|
|
width: 1080,
|
|
height: 566,
|
|
};
|
|
|
|
pub fn for_style(style: &CardStyle) -> Self {
|
|
match style {
|
|
CardStyle::Story => Self::INSTAGRAM_STORY,
|
|
CardStyle::Carousel => Self::INSTAGRAM_SQUARE,
|
|
_ => Self::INSTAGRAM_SQUARE,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub enum TextPosition {
|
|
Top,
|
|
#[default]
|
|
Center,
|
|
Bottom,
|
|
TopLeft,
|
|
TopRight,
|
|
BottomLeft,
|
|
BottomRight,
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CardResult {
|
|
pub image_path: String,
|
|
pub image_url: Option<String>,
|
|
pub text_content: String,
|
|
pub hashtags: Vec<String>,
|
|
pub caption: String,
|
|
pub style: String,
|
|
pub dimensions: (u32, u32),
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CardConfig {
|
|
pub style: CardStyle,
|
|
pub dimensions: CardDimensions,
|
|
pub text_position: TextPosition,
|
|
pub include_hashtags: bool,
|
|
pub include_caption: bool,
|
|
pub brand_watermark: Option<String>,
|
|
}
|
|
|
|
impl Default for CardConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
style: CardStyle::Modern,
|
|
dimensions: CardDimensions::INSTAGRAM_SQUARE,
|
|
text_position: TextPosition::Center,
|
|
include_hashtags: true,
|
|
include_caption: true,
|
|
brand_watermark: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
pub struct CardKeyword {
|
|
llm_provider: Arc<dyn LLMProvider>,
|
|
output_dir: String,
|
|
}
|
|
|
|
impl CardKeyword {
|
|
pub fn new(llm_provider: Arc<dyn LLMProvider>, output_dir: String) -> Self {
|
|
Self {
|
|
llm_provider,
|
|
output_dir,
|
|
}
|
|
}
|
|
|
|
|
|
pub async fn execute(
|
|
&self,
|
|
image_prompt: &str,
|
|
text_prompt: &str,
|
|
style: Option<&str>,
|
|
count: Option<usize>,
|
|
) -> Result<Vec<CardResult>> {
|
|
let card_style = style.map(CardStyle::from).unwrap_or_default();
|
|
let card_count = count.unwrap_or(1).min(10);
|
|
|
|
let config = CardConfig {
|
|
style: card_style.clone(),
|
|
dimensions: CardDimensions::for_style(&card_style),
|
|
..Default::default()
|
|
};
|
|
|
|
|
|
fs::create_dir_all(&self.output_dir)?;
|
|
|
|
let mut results = Vec::with_capacity(card_count);
|
|
|
|
for i in 0..card_count {
|
|
let result = self
|
|
.generate_single_card(image_prompt, text_prompt, &config, i)
|
|
.await?;
|
|
results.push(result);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
|
|
async fn generate_single_card(
|
|
&self,
|
|
image_prompt: &str,
|
|
text_prompt: &str,
|
|
config: &CardConfig,
|
|
index: usize,
|
|
) -> Result<CardResult> {
|
|
|
|
let text_content = self.generate_text_content(text_prompt, config).await?;
|
|
|
|
|
|
let enhanced_image_prompt = self.create_card_prompt(image_prompt, &text_content, config);
|
|
|
|
|
|
let image_bytes = self
|
|
.llm_provider
|
|
.generate_image(
|
|
&enhanced_image_prompt,
|
|
config.dimensions.width,
|
|
config.dimensions.height,
|
|
)
|
|
.await?;
|
|
|
|
|
|
let filename = format!(
|
|
"card_{}_{}.png",
|
|
chrono::Utc::now().format("%Y%m%d_%H%M%S"),
|
|
index
|
|
);
|
|
let image_path = format!("{}/{}", self.output_dir, filename);
|
|
|
|
|
|
if let Some(parent) = Path::new(&image_path).parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
fs::write(&image_path, &image_bytes)?;
|
|
|
|
|
|
let (hashtags, caption) = self.generate_social_content(&text_content, config).await?;
|
|
|
|
Ok(CardResult {
|
|
image_path: image_path.clone(),
|
|
image_url: None,
|
|
text_content,
|
|
hashtags,
|
|
caption,
|
|
style: format!("{:?}", config.style),
|
|
dimensions: (config.dimensions.width, config.dimensions.height),
|
|
})
|
|
}
|
|
|
|
|
|
async fn generate_text_content(
|
|
&self,
|
|
text_prompt: &str,
|
|
config: &CardConfig,
|
|
) -> Result<String> {
|
|
let style_instruction = match config.style {
|
|
CardStyle::Minimal => "Keep it very short, 1-2 impactful words or a brief phrase.",
|
|
CardStyle::Vibrant => "Make it energetic and exciting with action words.",
|
|
CardStyle::Dark => "Create a mysterious, sophisticated tone.",
|
|
CardStyle::Light => "Keep it uplifting and positive.",
|
|
CardStyle::Magazine => "Write like a magazine headline, catchy and professional.",
|
|
CardStyle::Story => "Create engaging story-style text that draws people in.",
|
|
_ => "Create compelling, shareable text perfect for social media.",
|
|
};
|
|
|
|
let prompt = format!(
|
|
r#"Create text for an Instagram post image overlay.
|
|
|
|
Topic/Theme: {}
|
|
|
|
Style Guidelines:
|
|
- {}
|
|
- Maximum 50 characters for main text
|
|
- Should be visually impactful when overlaid on an image
|
|
- Use proper capitalization for visual appeal
|
|
- No hashtags in the main text (those come separately)
|
|
|
|
Respond with ONLY the text content, nothing else."#,
|
|
text_prompt, style_instruction
|
|
);
|
|
|
|
let response = self.llm_provider.complete(&prompt, None).await?;
|
|
|
|
|
|
let text = response.trim().to_string();
|
|
|
|
|
|
if text.len() > 100 {
|
|
Ok(text.chars().take(100).collect::<String>() + "...")
|
|
} else {
|
|
Ok(text)
|
|
}
|
|
}
|
|
|
|
|
|
fn create_card_prompt(
|
|
&self,
|
|
image_prompt: &str,
|
|
text_content: &str,
|
|
config: &CardConfig,
|
|
) -> String {
|
|
let style_modifiers = match config.style {
|
|
CardStyle::Minimal => {
|
|
"minimalist, clean, simple composition, lots of negative space, muted colors"
|
|
}
|
|
CardStyle::Vibrant => "vibrant colors, high saturation, dynamic, energetic, bold",
|
|
CardStyle::Dark => "dark moody atmosphere, dramatic lighting, deep shadows, cinematic",
|
|
CardStyle::Light => "bright, airy, soft lighting, pastel colors, ethereal",
|
|
CardStyle::Gradient => "smooth color gradients, abstract, flowing colors",
|
|
CardStyle::Polaroid => "vintage polaroid style, slightly faded, warm tones, nostalgic",
|
|
CardStyle::Magazine => "high fashion, editorial style, professional photography, sharp",
|
|
CardStyle::Story => "vertical composition, immersive, storytelling, atmospheric",
|
|
CardStyle::Carousel => "consistent style, series-ready, cohesive aesthetic",
|
|
CardStyle::Modern => "modern, trendy, instagram aesthetic, high quality",
|
|
};
|
|
|
|
let text_position = match config.text_position {
|
|
TextPosition::Top => "at the top",
|
|
TextPosition::Center => "in the center",
|
|
TextPosition::Bottom => "at the bottom",
|
|
TextPosition::TopLeft => "in the top left corner",
|
|
TextPosition::TopRight => "in the top right corner",
|
|
TextPosition::BottomLeft => "in the bottom left corner",
|
|
TextPosition::BottomRight => "in the bottom right corner",
|
|
};
|
|
|
|
let text_color = match config.style {
|
|
CardStyle::Dark => "white",
|
|
CardStyle::Light => "dark gray or black",
|
|
_ => "white with a subtle shadow",
|
|
};
|
|
|
|
format!(
|
|
r#"Create an Instagram-ready image with the following specifications:
|
|
|
|
Background/Scene: {}
|
|
Style: {}, perfect for Instagram, professional quality, 4K, highly detailed
|
|
|
|
Text Overlay Requirements:
|
|
- Display the text "{}" {} of the image
|
|
- Use bold, modern typography
|
|
- Text color should be {} for readability
|
|
- Add a subtle text shadow or background blur behind text for contrast
|
|
- Leave appropriate padding around text
|
|
|
|
The image should be {} x {} pixels, optimized for social media.
|
|
Make the text an integral part of the design, not just overlaid."#,
|
|
image_prompt,
|
|
style_modifiers,
|
|
text_content,
|
|
text_position,
|
|
text_color,
|
|
config.dimensions.width,
|
|
config.dimensions.height
|
|
)
|
|
}
|
|
|
|
|
|
async fn generate_social_content(
|
|
&self,
|
|
text_content: &str,
|
|
config: &CardConfig,
|
|
) -> Result<(Vec<String>, String)> {
|
|
if !config.include_hashtags && !config.include_caption {
|
|
return Ok((vec![], String::new()));
|
|
}
|
|
|
|
let prompt = format!(
|
|
r#"Based on this Instagram post text: "{}"
|
|
|
|
Generate:
|
|
1. A short, engaging caption (1-2 sentences max)
|
|
2. 5-10 relevant hashtags (without the # symbol)
|
|
|
|
Format your response exactly like this:
|
|
CAPTION: [your caption here]
|
|
HASHTAGS: tag1, tag2, tag3, tag4, tag5"#,
|
|
text_content
|
|
);
|
|
|
|
let response = self.llm_provider.complete(&prompt, None).await?;
|
|
|
|
|
|
let mut caption = String::new();
|
|
let mut hashtags = Vec::new();
|
|
|
|
for line in response.lines() {
|
|
let line_trimmed = line.trim();
|
|
if line_trimmed.starts_with("CAPTION:") {
|
|
caption = line_trimmed
|
|
.trim_start_matches("CAPTION:")
|
|
.trim()
|
|
.to_string();
|
|
} else if line_trimmed.starts_with("HASHTAGS:") {
|
|
let tags = line_trimmed.trim_start_matches("HASHTAGS:").trim();
|
|
hashtags = tags
|
|
.split(',')
|
|
.map(|t| {
|
|
let tag = t.trim().trim_start_matches('#');
|
|
format!("#{}", tag)
|
|
})
|
|
.filter(|t| t.len() > 1)
|
|
.collect();
|
|
}
|
|
}
|
|
|
|
Ok((hashtags, caption))
|
|
}
|
|
}
|
|
|
|
|
|
pub fn register_card_keyword(runtime: &mut BasicRuntime, llm_provider: Arc<dyn LLMProvider>) {
|
|
let output_dir = runtime
|
|
.get_config("output_dir")
|
|
.unwrap_or_else(|| "/tmp/gb_cards".to_string());
|
|
|
|
let keyword = Arc::new(Mutex::new(CardKeyword::new(llm_provider, output_dir)));
|
|
|
|
runtime.register_keyword("CARD", move |args, _ctx| {
|
|
let keyword = keyword.clone();
|
|
Box::pin(async move {
|
|
if args.len() < 2 {
|
|
return Err(anyhow!(
|
|
"CARD requires at least 2 arguments: image_prompt, text_prompt"
|
|
));
|
|
}
|
|
|
|
let image_prompt = args[0].as_string()?;
|
|
let text_prompt = args[1].as_string()?;
|
|
let style = args.get(2).map(|v| v.as_string()).transpose()?;
|
|
let count = args
|
|
.get(3)
|
|
.map(|v| v.as_integer().map(|i| i as usize))
|
|
.transpose()?;
|
|
|
|
let keyword = keyword.lock().await;
|
|
let results = keyword
|
|
.execute(&image_prompt, &text_prompt, style.as_deref(), count)
|
|
.await?;
|
|
|
|
|
|
let result_values: Vec<BasicValue> = results
|
|
.into_iter()
|
|
.map(|r| {
|
|
BasicValue::Object(serde_json::json!({
|
|
"image_path": r.image_path,
|
|
"image_url": r.image_url,
|
|
"text": r.text_content,
|
|
"hashtags": r.hashtags,
|
|
"caption": r.caption,
|
|
"style": r.style,
|
|
"width": r.dimensions.0,
|
|
"height": r.dimensions.1,
|
|
}))
|
|
})
|
|
.collect();
|
|
|
|
if result_values.len() == 1 {
|
|
Ok(result_values.into_iter().next().unwrap_or_default())
|
|
} else {
|
|
Ok(BasicValue::Array(result_values))
|
|
}
|
|
})
|
|
});
|
|
}
|