use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delivery {
pub mode: String,
pub chat_id: Option<i64>,
pub agent_name: Option<String>,
}
impl Delivery {
pub fn telegram(chat_id: i64) -> Self {
Self { mode: "telegram".to_string(), chat_id: Some(chat_id), agent_name: None }
}
pub fn log() -> Self {
Self { mode: "log".to_string(), chat_id: None, agent_name: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronRequest {
pub schedule: String,
pub name: String,
pub description: String,
pub steps: Vec<StepSpec>,
pub delivery: Delivery,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepSpec {
pub action: String,
pub params: String,
}
pub fn detect_cron_intent(text: &str) -> Option<&'static str> {
let lower = text.to_lowercase();
let ko_patterns = [
"매일", "매주", "매달", "매시간",
"2시", "3시", "4시", "5시", "6시", "7시", "8시", "9시", "10시", "11시", "12시",
"오후", "오전", "새벽", "정오", "자정", "매시",
"30분마다", "2시간마다", "1시간마다",
];
for p in ko_patterns {
if lower.contains(p) {
return Some("ko");
}
}
let en_patterns = [
"daily", "weekly", "monthly",
"every hour", "every 2", "every 3", "hourly",
"cron", "schedule",
"at 2am", "at 3pm", "at 9am",
"every morning", "every night",
"@daily", "@hourly",
];
for p in en_patterns {
if lower.contains(p) {
return Some("en");
}
}
None
}
pub fn parse(text: &str, lang: &str) -> Option<CronRequest> {
match lang {
"ko" => parse_korean(text),
"en" => parse_english(text),
_ => parse_korean(text).or_else(|| parse_english(text)),
}
}
fn parse_korean(text: &str) -> Option<CronRequest> {
let schedule = extract_schedule_ko(text)?;
let name = extract_name_ko(text);
let description = text.to_string();
let steps = extract_steps_ko(text);
Some(CronRequest {
schedule,
name,
description,
steps,
delivery: Delivery::telegram(0), })
}
fn parse_english(text: &str) -> Option<CronRequest> {
let schedule = extract_schedule_en(text)?;
let name = extract_name_en(text);
let description = text.to_string();
let steps = extract_steps_en(text);
Some(CronRequest {
schedule,
name,
description,
steps,
delivery: Delivery::telegram(0),
})
}
fn extract_schedule_ko(text: &str) -> Option<String> {
let lower = text.to_lowercase();
let days_ko = [
("월요일", 1), ("화요일", 2), ("수요일", 3),
("목요일", 4), ("금요일", 5), ("토요일", 6), ("일요일", 0),
];
for (day, dow) in days_ko {
if lower.contains(day) {
let hour = extract_hour_from_ko_time(&lower).unwrap_or(9);
return Some(format!("0 {} * * {}", hour, dow));
}
}
if lower.contains("2시간") || lower.contains("두 시간") {
return Some("0 */2 * * *".to_string());
}
if lower.contains("3시간") {
return Some("0 */3 * * *".to_string());
}
if lower.contains("1시간") || lower.contains("한 시간") {
return Some("0 */1 * * *".to_string());
}
if lower.contains("30분") || lower.contains("15분") || lower.contains("10분") {
return Some("*/30 * * * *".to_string());
}
if let Some(cap) = extract_number_before(&lower, "시") {
let hour: u32 = cap.parse().unwrap_or(0).min(23);
return Some(format!("0 {} * * *", hour));
}
if lower.contains("매일") || lower.contains("매일 ") {
let hour = extract_hour_from_ko_time(&lower).unwrap_or(8);
return Some(format!("0 {} * * *", hour));
}
if let Some(cap) = extract_number_before(&lower, "일") {
let day: u32 = cap.parse().unwrap_or(1).min(28);
let hour = extract_hour_from_ko_time(&lower).unwrap_or(3);
return Some(format!("0 {} {} * *", hour, day));
}
if lower.contains("매") {
return Some("0 8 * * *".to_string());
}
None
}
fn extract_number_before(text: &str, keyword: &str) -> Option<String> {
if let Some(pos) = text.find(keyword) {
if pos > 0 {
let before = &text[..pos];
let digits: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
if !digits.is_empty() {
return Some(digits.chars().rev().collect());
}
if let Some(c) = before.chars().last() {
if '\u{0030}' <= c && c <= '\u{0039}' {
return Some(c.to_string());
}
}
}
}
None
}
fn extract_hour_from_ko_time(text: &str) -> Option<u32> {
let hour = extract_number_before(text, "시")?.parse::<u32>().ok()?;
if text.contains("오후") && hour < 12 {
return Some(hour + 12);
}
if text.contains("새벽") || text.contains("저녁") || text.contains("밤") {
if hour < 12 {
return Some(hour + 12);
}
}
Some(hour)
}
fn extract_schedule_en(text: &str) -> Option<String> {
let lower = text.to_lowercase();
if lower.contains("every hour") || lower.contains("hourly") {
return Some("0 * * * *".to_string());
}
if lower.contains("every 2 hours") || lower.contains("every two hours") {
return Some("0 */2 * * *".to_string());
}
if lower.contains("every 30 minutes") || lower.contains("every thirty minutes") {
return Some("*/30 * * * *".to_string());
}
if let Some(cap) = regex_capture(r"at (\d+)(am|pm)?", &lower) {
let hour: u32 = cap.parse().unwrap_or(0);
let is_pm = lower.contains("pm");
let h = if is_pm && hour < 12 { hour + 12 } else { hour };
return Some(format!("0 {} * * *", h));
}
if lower.contains("every day") || lower.contains("daily") {
return Some("0 8 * * *".to_string());
}
if lower.contains("every morning") {
return Some("0 8 * * *".to_string());
}
if lower.contains("every night") || lower.contains("every evening") {
return Some("0 21 * * *".to_string());
}
let en_days = [
("monday", 1), ("tuesday", 2), ("wednesday", 3),
("thursday", 4), ("friday", 5), ("saturday", 6), ("sunday", 0),
];
for (day, dow) in en_days {
if lower.contains(day) {
return Some(format!("0 9 * * {}", dow));
}
}
None
}
fn regex_capture(pattern: &str, text: &str) -> Option<String> {
if let Some(pos) = text.find("am") {
if pos > 0 {
let s = &text[..pos];
let digits: String = s.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
return Some(digits.chars().rev().collect());
}
}
if let Some(pos) = text.find("pm") {
if pos > 0 {
let s = &text[..pos];
let digits: String = s.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
let n: u32 = digits.chars().rev().collect::<String>().parse().unwrap_or(0);
return Some(format!("{}", n));
}
}
if let Some(pos) = text.find("at ") {
let rest = &text[pos + 3..];
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if !digits.is_empty() {
return Some(digits);
}
}
None
}
fn extract_name_ko(text: &str) -> String {
let lower = text.to_lowercase();
let words: Vec<&str> = text.split_whitespace().collect();
let delivery_keywords = ["이메일", "메일", "텔레그램", "telegram", "슬랙", "slack", "옵시디언", "obsidian"];
let schedule_keywords = ["매일", "매주", "매달", "매시간", "마다", "시", "마다", "cron", "schedule"];
let mut result = Vec::new();
let mut found_action = false;
for word in words {
let w = word.to_lowercase();
if schedule_keywords.iter().any(|k| w.contains(k)) {
continue;
}
if delivery_keywords.iter().any(|k| w.contains(k)) {
break;
}
result.push(word);
if !found_action {
found_action = true;
}
}
result.join(" ")
.trim()
.chars()
.take(50)
.collect::<String>()
}
fn extract_name_en(text: &str) -> String {
extract_name_ko(text) }
fn extract_steps_ko(text: &str) -> Vec<StepSpec> {
let mut steps = Vec::new();
let lower = text.to_lowercase();
if lower.contains("해커뉴스") || lower.contains("hackernews") || lower.contains("뉴스") {
steps.push(StepSpec {
action: "hackernews".to_string(),
params: "fetch top stories from Hacker News".to_string(),
});
}
if lower.contains("번역") || lower.contains("translate") || lower.contains("번역해") {
steps.push(StepSpec {
action: "translate".to_string(),
params: "translate content to Korean".to_string(),
});
}
if lower.contains("이메일") || lower.contains("email") || lower.contains("메일") || lower.contains("메일보내") {
steps.push(StepSpec {
action: "email".to_string(),
params: "send content as email".to_string(),
});
}
if lower.contains("obsidian") || lower.contains("옵시디언") || lower.contains("옵시디안") || lower.contains("노트") || lower.contains("기록") {
steps.push(StepSpec {
action: "write_file".to_string(),
params: "save as markdown note".to_string(),
});
}
if lower.contains("트렌딩") || lower.contains("깃허브") || lower.contains("github") || lower.contains("레포") {
steps.push(StepSpec {
action: "github".to_string(),
params: "fetch trending GitHub repositories".to_string(),
});
}
if lower.contains("요약") || lower.contains("summary") {
steps.push(StepSpec {
action: "agent".to_string(),
params: "summarize content".to_string(),
});
}
if steps.is_empty() {
steps.push(StepSpec {
action: "agent".to_string(),
params: text.to_string(),
});
}
steps
}
fn extract_steps_en(text: &str) -> Vec<StepSpec> {
let lower = text.to_lowercase();
let mut steps = Vec::new();
if lower.contains("hackernews") || lower.contains("scrape") || lower.contains("fetch news") {
steps.push(StepSpec { action: "hackernews".to_string(), params: "fetch top stories".to_string() });
}
if lower.contains("translate") || lower.contains("번역") {
steps.push(StepSpec { action: "translate".to_string(), params: "translate content".to_string() });
}
if lower.contains("email") || lower.contains("send") || lower.contains("mail") {
steps.push(StepSpec { action: "email".to_string(), params: "send as email".to_string() });
}
if lower.contains("obsidian") || lower.contains("save to") || lower.contains("write to") {
steps.push(StepSpec { action: "write_file".to_string(), params: "save as note".to_string() });
}
if lower.contains("github") || lower.contains("trending") || lower.contains("repository") {
steps.push(StepSpec { action: "github".to_string(), params: "fetch trending repos".to_string() });
}
if lower.contains("summarize") || lower.contains("summary") {
steps.push(StepSpec { action: "agent".to_string(), params: "summarize content".to_string() });
}
if steps.is_empty() {
steps.push(StepSpec { action: "agent".to_string(), params: text.to_string() });
}
steps
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_cron_intent_korean() {
assert_eq!(detect_cron_intent("매일 2시에 해커뉴스 번역해줘"), Some("ko"));
assert_eq!(detect_cron_intent("2시간마다 보고해줘"), Some("ko"));
assert_eq!(detect_cron_intent("매주 월요일 알림"), Some("ko"));
assert_eq!(detect_cron_intent("안녕 오늘天气怎么样"), None);
}
#[test]
fn test_detect_cron_intent_english() {
assert_eq!(detect_cron_intent("every day at 2am fetch hackernews"), Some("en"));
assert_eq!(detect_cron_intent("hourly check status"), Some("en"));
assert_eq!(detect_cron_intent("hello how are you"), None);
}
#[test]
fn test_parse_korean_schedule() {
let req = parse_korean("매일 2시에 해커뉴스 번역해줘").unwrap();
assert_eq!(req.schedule, "0 2 * * *");
assert!(req.name.contains("해커") || req.name.contains("번역"));
let req2 = parse_korean("2시간마다 보고해줘").unwrap();
assert_eq!(req2.schedule, "0 */2 * * *");
let req3 = parse_korean("매주 월요일 9시에 알림").unwrap();
assert_eq!(req3.schedule, "0 9 * * 1");
}
#[test]
fn test_parse_english_schedule() {
let req = parse_english("daily at 8am fetch hackernews").unwrap();
assert_eq!(req.schedule, "0 8 * * *");
let req2 = parse_english("every 2 hours check status").unwrap();
assert_eq!(req2.schedule, "0 */2 * * *");
}
#[test]
fn test_extract_steps_ko() {
let steps = extract_steps_ko("매일 2시에 해커뉴스 번역해서 이메일로 보내줘");
assert!(steps.iter().any(|s| s.action == "hackernews"));
assert!(steps.iter().any(|s| s.action == "translate"));
assert!(steps.iter().any(|s| s.action == "email"));
let steps2 = extract_steps_ko("매일 트렌딩 레포 분석해서 Obsidian에 저장해줘");
assert!(steps2.iter().any(|s| s.action == "github"));
assert!(steps2.iter().any(|s| s.action == "write_file"));
}
#[test]
fn test_no_cron() {
assert!(parse("hello world", "en").is_none());
assert!(parse("반가워요", "ko").is_none());
}
}