botserver/src/basic/keywords/card.rs
Rodrigo Rodriguez (Pragmatismo) a5dee11002 Security audit: Remove all production .unwrap()/.expect(), add SafeCommand, ErrorSanitizer
- 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
2025-12-28 21:26:08 -03:00

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