1use crate::commands::{Command, OutputFormat};
6use crate::errors::prelude::{CliError, Result as CliResult};
7use crate::output::{CliResponse, OutputFormatter};
8use crate::scheduler::{ScheduleType, Scheduler, TaskType};
9use crate::utils::parse_schedule_time;
10use async_trait::async_trait;
11use chrono::Utc;
12use clap::{Subcommand, ValueHint};
13use colored::Colorize;
14use serde_json::json;
15use std::str::FromStr;
16use vkteams_bot::prelude::*;
17
18#[derive(Subcommand, Debug, Clone)]
20pub enum SchedulingCommands {
21 Schedule {
23 #[command(subcommand)]
24 message_type: ScheduleMessageType,
25 },
26 Scheduler {
28 #[command(subcommand)]
29 action: SchedulerAction,
30 },
31 Task {
33 #[command(subcommand)]
34 action: TaskAction,
35 },
36}
37
38#[derive(Subcommand, Debug, Clone)]
39pub enum ScheduleMessageType {
40 Text {
42 #[arg(short = 'u', long, required = true, value_name = "CHAT_ID", value_hint = ValueHint::Username)]
43 chat_id: String,
44 #[arg(short = 'm', long, required = true, value_name = "MESSAGE")]
45 message: String,
46 #[arg(short = 't', long, value_name = "TIME")]
47 time: Option<String>,
48 #[arg(short = 'c', long, value_name = "CRON")]
49 cron: Option<String>,
50 #[arg(short = 'i', long, value_name = "SECONDS")]
51 interval: Option<u64>,
52 #[arg(long, value_name = "RUNS")]
53 max_runs: Option<u64>,
54 },
55 File {
57 #[arg(short = 'u', long, required = true, value_name = "CHAT_ID", value_hint = ValueHint::Username)]
58 chat_id: String,
59 #[arg(short = 'p', long, required = true, value_name = "FILE_PATH", value_hint = ValueHint::FilePath)]
60 file_path: String,
61 #[arg(short = 't', long, value_name = "TIME")]
62 time: Option<String>,
63 #[arg(short = 'c', long, value_name = "CRON")]
64 cron: Option<String>,
65 #[arg(short = 'i', long, value_name = "SECONDS")]
66 interval: Option<u64>,
67 #[arg(long, value_name = "RUNS")]
68 max_runs: Option<u64>,
69 },
70 Voice {
72 #[arg(short = 'u', long, required = true, value_name = "CHAT_ID", value_hint = ValueHint::Username)]
73 chat_id: String,
74 #[arg(short = 'p', long, required = true, value_name = "FILE_PATH", value_hint = ValueHint::FilePath)]
75 file_path: String,
76 #[arg(short = 't', long, value_name = "TIME")]
77 time: Option<String>,
78 #[arg(short = 'c', long, value_name = "CRON")]
79 cron: Option<String>,
80 #[arg(short = 'i', long, value_name = "SECONDS")]
81 interval: Option<u64>,
82 #[arg(long, value_name = "RUNS")]
83 max_runs: Option<u64>,
84 },
85 Action {
87 #[arg(short = 'u', long, required = true, value_name = "CHAT_ID", value_hint = ValueHint::Username)]
88 chat_id: String,
89 #[arg(short = 'a', long, required = true, value_name = "ACTION")]
90 action: String,
91 #[arg(short = 't', long, value_name = "TIME")]
92 time: Option<String>,
93 #[arg(short = 'c', long, value_name = "CRON")]
94 cron: Option<String>,
95 #[arg(short = 'i', long, value_name = "SECONDS")]
96 interval: Option<u64>,
97 #[arg(long, value_name = "RUNS")]
98 max_runs: Option<u64>,
99 },
100}
101
102#[derive(Subcommand, Debug, Clone)]
103pub enum SchedulerAction {
104 Start,
106 Stop,
108 Status,
110 List,
112}
113
114#[derive(Subcommand, Debug, Clone)]
115pub enum TaskAction {
116 Show {
118 #[arg(required = true, value_name = "TASK_ID")]
119 task_id: String,
120 },
121 Remove {
123 #[arg(required = true, value_name = "TASK_ID")]
124 task_id: String,
125 },
126 Enable {
128 #[arg(required = true, value_name = "TASK_ID")]
129 task_id: String,
130 },
131 Disable {
133 #[arg(required = true, value_name = "TASK_ID")]
134 task_id: String,
135 },
136 Run {
138 #[arg(required = true, value_name = "TASK_ID")]
139 task_id: String,
140 },
141}
142
143#[async_trait]
144impl Command for SchedulingCommands {
145 async fn execute(&self, bot: &Bot) -> CliResult<()> {
146 match self {
147 SchedulingCommands::Schedule { message_type } => {
148 execute_schedule(bot, message_type).await
149 }
150 SchedulingCommands::Scheduler { action } => execute_scheduler_action(bot, action).await,
151 SchedulingCommands::Task { action } => execute_task_action(bot, action).await,
152 }
153 }
154
155 fn name(&self) -> &'static str {
156 match self {
157 SchedulingCommands::Schedule { .. } => "schedule",
158 SchedulingCommands::Scheduler { .. } => "scheduler",
159 SchedulingCommands::Task { .. } => "task",
160 }
161 }
162
163 fn validate(&self) -> CliResult<()> {
164 match self {
165 SchedulingCommands::Schedule { message_type } => {
166 validate_schedule_command(message_type)
167 }
168 SchedulingCommands::Scheduler { action } => validate_scheduler_action(action),
169 SchedulingCommands::Task { action } => validate_task_action(action),
170 }
171 }
172
173 async fn execute_with_output(&self, bot: &Bot, output_format: &OutputFormat) -> CliResult<()> {
175 let response = match self {
176 SchedulingCommands::Schedule { message_type } => {
177 execute_schedule_structured(bot, message_type).await
178 }
179 SchedulingCommands::Scheduler { action } => {
180 execute_scheduler_action_structured(bot, action).await
181 }
182 SchedulingCommands::Task { action } => {
183 execute_task_action_structured(bot, action).await
184 }
185 };
186
187 match response {
188 Ok(resp) => OutputFormatter::print(&resp, output_format),
189 Err(e) => {
190 let error_response =
191 CliResponse::<serde_json::Value>::error("scheduling-command", e.to_string());
192 OutputFormatter::print(&error_response, output_format)?;
193 Err(e)
194 }
195 }
196 }
197}
198
199async fn execute_schedule(_bot: &Bot, message_type: &ScheduleMessageType) -> CliResult<()> {
201 let mut scheduler = Scheduler::new(None).await?;
202 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
205 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
206 let url = std::env::var("VKTEAMS_BOT_API_URL")
207 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
208 let scheduler_bot =
209 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
210 scheduler.set_bot(scheduler_bot);
211
212 let (task_type, schedule, max_runs) = match message_type {
213 ScheduleMessageType::Text {
214 chat_id,
215 message,
216 time,
217 cron,
218 interval,
219 max_runs,
220 } => {
221 let task = TaskType::SendText {
222 chat_id: chat_id.clone(),
223 message: message.clone(),
224 };
225 let schedule = parse_schedule_args(time, cron, interval)?;
226 (task, schedule, *max_runs)
227 }
228 ScheduleMessageType::File {
229 chat_id,
230 file_path,
231 time,
232 cron,
233 interval,
234 max_runs,
235 } => {
236 let task = TaskType::SendFile {
237 chat_id: chat_id.clone(),
238 file_path: file_path.clone(),
239 };
240 let schedule = parse_schedule_args(time, cron, interval)?;
241 (task, schedule, *max_runs)
242 }
243 ScheduleMessageType::Voice {
244 chat_id,
245 file_path,
246 time,
247 cron,
248 interval,
249 max_runs,
250 } => {
251 let task = TaskType::SendVoice {
252 chat_id: chat_id.clone(),
253 file_path: file_path.clone(),
254 };
255 let schedule = parse_schedule_args(time, cron, interval)?;
256 (task, schedule, *max_runs)
257 }
258 ScheduleMessageType::Action {
259 chat_id,
260 action,
261 time,
262 cron,
263 interval,
264 max_runs,
265 } => {
266 let task = TaskType::SendAction {
267 chat_id: chat_id.clone(),
268 action: action.clone(),
269 };
270 let schedule = parse_schedule_args(time, cron, interval)?;
271 (task, schedule, *max_runs)
272 }
273 };
274
275 let task_id = scheduler.add_task(task_type, schedule, max_runs).await?;
276 println!(
277 "✅ Task scheduled successfully with ID: {}",
278 task_id.green()
279 );
280 Ok(())
281}
282
283async fn execute_scheduler_action(_bot: &Bot, action: &SchedulerAction) -> CliResult<()> {
284 let mut scheduler = Scheduler::new(None).await?;
285 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
288 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
289 let url = std::env::var("VKTEAMS_BOT_API_URL")
290 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
291 let scheduler_bot =
292 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
293 scheduler.set_bot(scheduler_bot);
294
295 match action {
296 SchedulerAction::Start => {
297 println!("🚀 Starting scheduler daemon...");
298 scheduler.run_scheduler().await?;
299 }
300 SchedulerAction::Stop => {
301 println!("⏹️ Stopping scheduler daemon...");
302 stop_scheduler_daemon().await?;
303 println!("✅ Scheduler daemon stopped successfully");
304 }
305 SchedulerAction::Status => {
306 let daemon_status = scheduler.get_daemon_status().await;
307
308 println!("📊 Scheduler Status:");
309
310 match &daemon_status.status {
312 crate::scheduler::DaemonStatus::NotRunning => {
313 println!(" Daemon: {} (Not running)", "⏹️ Stopped".red());
314 }
315 crate::scheduler::DaemonStatus::Running { pid, started_at } => {
316 println!(
317 " Daemon: {} (PID: {}, Started: {})",
318 "🟢 Running".green(),
319 pid,
320 started_at.format("%Y-%m-%d %H:%M:%S UTC")
321 );
322 }
323 crate::scheduler::DaemonStatus::Stale { pid, started_at } => {
324 println!(
325 " Daemon: {} (Stale PID: {}, Started: {})",
326 "⚠️ Stale".yellow(),
327 pid,
328 started_at.format("%Y-%m-%d %H:%M:%S UTC")
329 );
330 }
331 crate::scheduler::DaemonStatus::Unknown(msg) => {
332 println!(" Daemon: {} ({})", "❓ Unknown".yellow(), msg);
333 }
334 }
335
336 println!(" PID file: {:?}", daemon_status.pid_file_path);
337 println!(" Total tasks: {}", daemon_status.total_tasks);
338 println!(
339 " Enabled tasks: {}",
340 daemon_status.enabled_tasks.to_string().green()
341 );
342 println!(
343 " Disabled tasks: {}",
344 (daemon_status.total_tasks - daemon_status.enabled_tasks)
345 .to_string()
346 .yellow()
347 );
348 }
349 SchedulerAction::List => {
350 let tasks = scheduler.list_tasks().await;
351
352 if tasks.is_empty() {
353 println!("📭 No scheduled tasks found");
354 return Ok(());
355 }
356
357 println!("📋 Scheduled Tasks:");
358 for task in tasks {
359 let status = if task.enabled {
360 "✅ Active".green()
361 } else {
362 "⏸️ Disabled".yellow()
363 };
364 println!(
365 " {} [{}] {}",
366 task.id,
367 status,
368 task.task_type.description()
369 );
370 println!(" Schedule: {}", task.schedule.description());
371 println!(
372 " Runs: {}/{}",
373 task.run_count,
374 task.max_runs.map_or("∞".to_string(), |m| m.to_string())
375 );
376 println!(
377 " Next run: {}",
378 task.next_run.format("%Y-%m-%d %H:%M:%S UTC")
379 );
380 println!();
381 }
382 }
383 }
384 Ok(())
385}
386
387async fn execute_task_action(_bot: &Bot, action: &TaskAction) -> CliResult<()> {
388 let mut scheduler = Scheduler::new(None).await?;
389 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
392 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
393 let url = std::env::var("VKTEAMS_BOT_API_URL")
394 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
395 let scheduler_bot =
396 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
397 scheduler.set_bot(scheduler_bot);
398
399 match action {
400 TaskAction::Show { task_id } => {
401 if let Some(task) = scheduler.get_task(task_id).await {
402 println!("📋 Task Details:");
403 println!(" ID: {}", task.id);
404 println!(" Type: {}", task.task_type.description());
405 println!(" Schedule: {}", task.schedule.description());
406 println!(
407 " Status: {}",
408 if task.enabled {
409 "✅ Active".green()
410 } else {
411 "⏸️ Disabled".yellow()
412 }
413 );
414 println!(
415 " Created: {}",
416 task.created_at.format("%Y-%m-%d %H:%M:%S UTC")
417 );
418 println!(
419 " Runs: {}/{}",
420 task.run_count,
421 task.max_runs.map_or("∞".to_string(), |m| m.to_string())
422 );
423 println!(
424 " Next run: {}",
425 task.next_run.format("%Y-%m-%d %H:%M:%S UTC")
426 );
427 if let Some(last_run) = task.last_run {
428 println!(" Last run: {}", last_run.format("%Y-%m-%d %H:%M:%S UTC"));
429 }
430 } else {
431 return Err(CliError::InputError(format!("Task not found: {task_id}")));
432 }
433 }
434 TaskAction::Remove { task_id } => {
435 scheduler.remove_task(task_id).await?;
436 println!("🗑️ Task {} removed successfully", task_id.green());
437 }
438 TaskAction::Enable { task_id } => {
439 scheduler.enable_task(task_id).await?;
440 println!("✅ Task {} enabled successfully", task_id.green());
441 }
442 TaskAction::Disable { task_id } => {
443 scheduler.disable_task(task_id).await?;
444 println!("⏸️ Task {} disabled successfully", task_id.yellow());
445 }
446 TaskAction::Run { task_id } => {
447 println!("🚀 Running task {task_id} once...");
448 scheduler.run_task_once(task_id).await?;
449 println!("✅ Task {} executed successfully", task_id.green());
450 }
451 }
452 Ok(())
453}
454
455fn parse_schedule_args(
457 time: &Option<String>,
458 cron: &Option<String>,
459 interval: &Option<u64>,
460) -> CliResult<ScheduleType> {
461 let count = [time.is_some(), cron.is_some(), interval.is_some()]
462 .iter()
463 .filter(|&&x| x)
464 .count();
465
466 if count == 0 {
467 return Err(CliError::InputError(
468 "One of --time, --cron, or --interval must be specified".to_string(),
469 ));
470 }
471
472 if count > 1 {
473 return Err(CliError::InputError(
474 "Only one of --time, --cron, or --interval can be specified".to_string(),
475 ));
476 }
477
478 if let Some(time_str) = time {
479 let datetime = parse_schedule_time(time_str)?;
480 Ok(ScheduleType::Once(datetime))
481 } else if let Some(cron_expr) = cron {
482 cron::Schedule::from_str(cron_expr)
484 .map_err(|e| CliError::InputError(format!("Invalid cron expression: {e}")))?;
485 Ok(ScheduleType::Cron(cron_expr.clone()))
486 } else if let Some(interval_secs) = interval {
487 if *interval_secs == 0 {
488 return Err(CliError::InputError(
489 "Interval must be greater than 0".to_string(),
490 ));
491 }
492 Ok(ScheduleType::Interval {
493 duration_seconds: *interval_secs,
494 start_time: Utc::now(),
495 })
496 } else {
497 unreachable!()
498 }
499}
500
501fn validate_schedule_command(message_type: &ScheduleMessageType) -> CliResult<()> {
503 match message_type {
504 ScheduleMessageType::Text {
505 chat_id,
506 message,
507 time,
508 cron,
509 interval,
510 max_runs,
511 } => {
512 validate_chat_id(chat_id)?;
513 validate_message_content(message)?;
514 parse_schedule_args(time, cron, interval)?;
516 validate_max_runs(max_runs)?;
517 }
518 ScheduleMessageType::File {
519 chat_id,
520 file_path,
521 time,
522 cron,
523 interval,
524 max_runs,
525 } => {
526 validate_chat_id(chat_id)?;
527 validate_file_path_arg(file_path)?;
528 parse_schedule_args(time, cron, interval)?;
529 validate_max_runs(max_runs)?;
530 }
531 ScheduleMessageType::Voice {
532 chat_id,
533 file_path,
534 time,
535 cron,
536 interval,
537 max_runs,
538 } => {
539 validate_chat_id(chat_id)?;
540 validate_voice_file_path(file_path)?;
541 parse_schedule_args(time, cron, interval)?;
542 validate_max_runs(max_runs)?;
543 }
544 ScheduleMessageType::Action {
545 chat_id,
546 action,
547 time,
548 cron,
549 interval,
550 max_runs,
551 } => {
552 validate_chat_id(chat_id)?;
553 validate_action_type(action)?;
554 parse_schedule_args(time, cron, interval)?;
555 validate_max_runs(max_runs)?;
556 }
557 }
558 Ok(())
559}
560
561fn validate_scheduler_action(action: &SchedulerAction) -> CliResult<()> {
562 match action {
564 SchedulerAction::Start
565 | SchedulerAction::Stop
566 | SchedulerAction::Status
567 | SchedulerAction::List => Ok(()),
568 }
569}
570
571fn validate_task_action(action: &TaskAction) -> CliResult<()> {
572 match action {
573 TaskAction::Show { task_id }
574 | TaskAction::Remove { task_id }
575 | TaskAction::Enable { task_id }
576 | TaskAction::Disable { task_id }
577 | TaskAction::Run { task_id } => validate_task_id(task_id),
578 }
579}
580
581fn validate_chat_id(chat_id: &str) -> CliResult<()> {
583 if chat_id.trim().is_empty() {
584 return Err(CliError::InputError("Chat ID cannot be empty".to_string()));
585 }
586 if chat_id.len() > 100 {
587 return Err(CliError::InputError(
588 "Chat ID is too long (max 100 characters)".to_string(),
589 ));
590 }
591 Ok(())
592}
593
594fn validate_message_content(message: &str) -> CliResult<()> {
595 if message.trim().is_empty() {
596 return Err(CliError::InputError(
597 "Message content cannot be empty".to_string(),
598 ));
599 }
600 if message.len() > 10000 {
601 return Err(CliError::InputError(
602 "Message is too long (max 10000 characters)".to_string(),
603 ));
604 }
605 Ok(())
606}
607
608fn validate_file_path_arg(file_path: &str) -> CliResult<()> {
609 if file_path.trim().is_empty() {
610 return Err(CliError::InputError(
611 "File path cannot be empty".to_string(),
612 ));
613 }
614 if !std::path::Path::new(file_path).exists() {
615 return Err(CliError::InputError(format!(
616 "File does not exist: {file_path}"
617 )));
618 }
619 Ok(())
620}
621
622fn validate_voice_file_path(file_path: &str) -> CliResult<()> {
623 validate_file_path_arg(file_path)?;
624
625 let path = std::path::Path::new(file_path);
627 if let Some(extension) = path.extension() {
628 let ext = extension.to_string_lossy().to_lowercase();
629 if !["ogg", "mp3", "wav", "m4a", "aac"].contains(&ext.as_str()) {
630 return Err(CliError::InputError(format!(
631 "Unsupported voice file format: {ext}. Supported: ogg, mp3, wav, m4a, aac"
632 )));
633 }
634 } else {
635 return Err(CliError::InputError(
636 "Voice file must have a valid extension".to_string(),
637 ));
638 }
639
640 Ok(())
641}
642
643fn validate_action_type(action: &str) -> CliResult<()> {
644 if action.trim().is_empty() {
645 return Err(CliError::InputError(
646 "Action type cannot be empty".to_string(),
647 ));
648 }
649
650 let valid_actions = [
652 "typing",
653 "upload_photo",
654 "record_video",
655 "upload_video",
656 "record_audio",
657 "upload_audio",
658 "upload_document",
659 "find_location",
660 ];
661 if !valid_actions.contains(&action) {
662 return Err(CliError::InputError(format!(
663 "Unsupported action type: {}. Supported: {}",
664 action,
665 valid_actions.join(", ")
666 )));
667 }
668
669 Ok(())
670}
671
672fn validate_max_runs(max_runs: &Option<u64>) -> CliResult<()> {
673 if let Some(runs) = max_runs {
674 if *runs == 0 {
675 return Err(CliError::InputError(
676 "Max runs must be greater than 0".to_string(),
677 ));
678 }
679 if *runs > 10000 {
680 return Err(CliError::InputError(
681 "Max runs cannot exceed 10000".to_string(),
682 ));
683 }
684 }
685 Ok(())
686}
687
688fn validate_task_id(task_id: &str) -> CliResult<()> {
689 if task_id.trim().is_empty() {
690 return Err(CliError::InputError("Task ID cannot be empty".to_string()));
691 }
692 if task_id.len() > 50 {
693 return Err(CliError::InputError(
694 "Task ID is too long (max 50 characters)".to_string(),
695 ));
696 }
697 Ok(())
698}
699
700async fn execute_schedule_structured(
702 _bot: &Bot,
703 message_type: &ScheduleMessageType,
704) -> CliResult<CliResponse<serde_json::Value>> {
705 let mut scheduler = Scheduler::new(None).await?;
706 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
707 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
708 let url = std::env::var("VKTEAMS_BOT_API_URL")
709 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
710 let scheduler_bot =
711 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
712 scheduler.set_bot(scheduler_bot);
713
714 let (task_type, schedule, max_runs) = match message_type {
715 ScheduleMessageType::Text {
716 chat_id,
717 message,
718 time,
719 cron,
720 interval,
721 max_runs,
722 } => {
723 let task = TaskType::SendText {
724 chat_id: chat_id.clone(),
725 message: message.clone(),
726 };
727 let schedule = parse_schedule_args(time, cron, interval)?;
728 (task, schedule, *max_runs)
729 }
730 ScheduleMessageType::File {
731 chat_id,
732 file_path,
733 time,
734 cron,
735 interval,
736 max_runs,
737 } => {
738 let task = TaskType::SendFile {
739 chat_id: chat_id.clone(),
740 file_path: file_path.clone(),
741 };
742 let schedule = parse_schedule_args(time, cron, interval)?;
743 (task, schedule, *max_runs)
744 }
745 ScheduleMessageType::Voice {
746 chat_id,
747 file_path,
748 time,
749 cron,
750 interval,
751 max_runs,
752 } => {
753 let task = TaskType::SendVoice {
754 chat_id: chat_id.clone(),
755 file_path: file_path.clone(),
756 };
757 let schedule = parse_schedule_args(time, cron, interval)?;
758 (task, schedule, *max_runs)
759 }
760 ScheduleMessageType::Action {
761 chat_id,
762 action,
763 time,
764 cron,
765 interval,
766 max_runs,
767 } => {
768 let task = TaskType::SendAction {
769 chat_id: chat_id.clone(),
770 action: action.clone(),
771 };
772 let schedule = parse_schedule_args(time, cron, interval)?;
773 (task, schedule, *max_runs)
774 }
775 };
776
777 let task_id = scheduler
778 .add_task(task_type.clone(), schedule.clone(), max_runs)
779 .await?;
780
781 Ok(CliResponse::success(
782 "schedule",
783 json!({
784 "task_id": task_id,
785 "task_type": task_type.description(),
786 "schedule": schedule.description(),
787 "max_runs": max_runs.map_or("unlimited".to_string(), |m| m.to_string()),
788 "message": format!("Task scheduled successfully with ID: {}", task_id)
789 }),
790 ))
791}
792
793async fn execute_scheduler_action_structured(
794 _bot: &Bot,
795 action: &SchedulerAction,
796) -> CliResult<CliResponse<serde_json::Value>> {
797 let mut scheduler = Scheduler::new(None).await?;
798 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
799 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
800 let url = std::env::var("VKTEAMS_BOT_API_URL")
801 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
802 let scheduler_bot =
803 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
804 scheduler.set_bot(scheduler_bot);
805
806 match action {
807 SchedulerAction::Start => {
808 scheduler.run_scheduler().await?;
809 Ok(CliResponse::success(
810 "scheduler-start",
811 json!({
812 "action": "start",
813 "message": "Scheduler daemon started successfully"
814 }),
815 ))
816 }
817 SchedulerAction::Stop => {
818 stop_scheduler_daemon().await?;
819 Ok(CliResponse::success(
820 "scheduler-stop",
821 json!({
822 "action": "stop",
823 "message": "Scheduler daemon stopped successfully"
824 }),
825 ))
826 }
827 SchedulerAction::Status => {
828 let daemon_status = scheduler.get_daemon_status().await;
829
830 let status_str = match &daemon_status.status {
831 crate::scheduler::DaemonStatus::NotRunning => "stopped",
832 crate::scheduler::DaemonStatus::Running { .. } => "running",
833 crate::scheduler::DaemonStatus::Stale { .. } => "stale",
834 crate::scheduler::DaemonStatus::Unknown(_) => "unknown",
835 };
836
837 let daemon_info = match &daemon_status.status {
838 crate::scheduler::DaemonStatus::Running { pid, started_at }
839 | crate::scheduler::DaemonStatus::Stale { pid, started_at } => {
840 json!({
841 "pid": pid,
842 "started_at": started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string()
843 })
844 }
845 _ => json!(null),
846 };
847
848 Ok(CliResponse::success(
849 "scheduler-status",
850 json!({
851 "daemon_status": status_str,
852 "daemon_info": daemon_info,
853 "pid_file_path": daemon_status.pid_file_path.to_string_lossy(),
854 "total_tasks": daemon_status.total_tasks,
855 "enabled_tasks": daemon_status.enabled_tasks,
856 "disabled_tasks": daemon_status.total_tasks - daemon_status.enabled_tasks
857 }),
858 ))
859 }
860 SchedulerAction::List => {
861 let tasks = scheduler.list_tasks().await;
862
863 let tasks_json: Vec<serde_json::Value> = tasks.into_iter().map(|task| {
864 json!({
865 "id": task.id,
866 "enabled": task.enabled,
867 "task_type": task.task_type.description(),
868 "schedule": task.schedule.description(),
869 "run_count": task.run_count,
870 "max_runs": task.max_runs.map_or("unlimited".to_string(), |m| m.to_string()),
871 "next_run": task.next_run.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
872 "last_run": task.last_run.map(|lr| lr.format("%Y-%m-%d %H:%M:%S UTC").to_string()),
873 "created_at": task.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string()
874 })
875 }).collect();
876
877 Ok(CliResponse::success(
878 "scheduler-list",
879 json!({
880 "tasks": tasks_json,
881 "total": tasks_json.len()
882 }),
883 ))
884 }
885 }
886}
887
888async fn execute_task_action_structured(
889 _bot: &Bot,
890 action: &TaskAction,
891) -> CliResult<CliResponse<serde_json::Value>> {
892 let mut scheduler = Scheduler::new(None).await?;
893 let token = std::env::var("VKTEAMS_BOT_API_TOKEN")
894 .map_err(|_| CliError::InputError("Bot token not available".to_string()))?;
895 let url = std::env::var("VKTEAMS_BOT_API_URL")
896 .map_err(|_| CliError::InputError("Bot URL not available".to_string()))?;
897 let scheduler_bot =
898 Bot::with_params(&APIVersionUrl::V1, &token, &url).map_err(CliError::ApiError)?;
899 scheduler.set_bot(scheduler_bot);
900
901 match action {
902 TaskAction::Show { task_id } => {
903 if let Some(task) = scheduler.get_task(task_id).await {
904 Ok(CliResponse::success(
905 "task-show",
906 json!({
907 "task": {
908 "id": task.id,
909 "type": task.task_type.description(),
910 "schedule": task.schedule.description(),
911 "enabled": task.enabled,
912 "created_at": task.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
913 "run_count": task.run_count,
914 "max_runs": task.max_runs.map_or("unlimited".to_string(), |m| m.to_string()),
915 "next_run": task.next_run.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
916 "last_run": task.last_run.map(|lr| lr.format("%Y-%m-%d %H:%M:%S UTC").to_string())
917 }
918 }),
919 ))
920 } else {
921 Err(CliError::InputError(format!("Task not found: {task_id}")))
922 }
923 }
924 TaskAction::Remove { task_id } => {
925 scheduler.remove_task(task_id).await?;
926 Ok(CliResponse::success(
927 "task-remove",
928 json!({
929 "action": "remove",
930 "task_id": task_id,
931 "message": format!("Task {} removed successfully", task_id)
932 }),
933 ))
934 }
935 TaskAction::Enable { task_id } => {
936 scheduler.enable_task(task_id).await?;
937 Ok(CliResponse::success(
938 "task-enable",
939 json!({
940 "action": "enable",
941 "task_id": task_id,
942 "message": format!("Task {} enabled successfully", task_id)
943 }),
944 ))
945 }
946 TaskAction::Disable { task_id } => {
947 scheduler.disable_task(task_id).await?;
948 Ok(CliResponse::success(
949 "task-disable",
950 json!({
951 "action": "disable",
952 "task_id": task_id,
953 "message": format!("Task {} disabled successfully", task_id)
954 }),
955 ))
956 }
957 TaskAction::Run { task_id } => {
958 scheduler.run_task_once(task_id).await?;
959 Ok(CliResponse::success(
960 "task-run",
961 json!({
962 "action": "run",
963 "task_id": task_id,
964 "message": format!("Task {} executed successfully", task_id)
965 }),
966 ))
967 }
968 }
969}
970
971async fn stop_scheduler_daemon() -> CliResult<()> {
973 use std::fs;
974
975 let temp_dir = std::env::temp_dir();
977 let stop_file = temp_dir.join("vkteams_scheduler_stop.signal");
978
979 fs::write(&stop_file, "stop")
981 .map_err(|e| CliError::FileError(format!("Failed to create stop signal file: {e}")))?;
982
983 let mut attempts = 0;
985 const MAX_ATTEMPTS: u32 = 300; while attempts < MAX_ATTEMPTS && stop_file.exists() {
988 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
989 attempts += 1;
990 }
991
992 if stop_file.exists() {
993 let _ = fs::remove_file(&stop_file);
995 return Err(CliError::UnexpectedError(
996 "Daemon did not respond to stop signal within 30 seconds".to_string(),
997 ));
998 }
999
1000 Ok(())
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use std::env;
1007 use tokio::runtime::Runtime;
1008
1009 fn dummy_bot() -> Bot {
1010 Bot::with_params(&APIVersionUrl::V1, "dummy_token", "https://dummy.api.com").unwrap()
1011 }
1012
1013 fn set_env_vars() {
1015 unsafe {
1016 env::set_var("VKTEAMS_BOT_API_TOKEN", "dummy_token");
1017 env::set_var("VKTEAMS_BOT_API_URL", "https://dummy.api.com");
1018
1019 let thread_id = std::thread::current().id();
1021 let timestamp = std::time::SystemTime::now()
1022 .duration_since(std::time::UNIX_EPOCH)
1023 .unwrap()
1024 .as_nanos();
1025 let temp_dir =
1026 std::env::temp_dir().join(format!("vkteams_bot_test_{thread_id:?}_{timestamp}"));
1027 std::fs::create_dir_all(&temp_dir).ok();
1028 env::set_var("HOME", temp_dir.to_string_lossy().to_string());
1029 }
1030 }
1031
1032 #[allow(dead_code)]
1034 fn setup_test_env() -> tempfile::TempDir {
1035 unsafe {
1036 env::set_var("VKTEAMS_BOT_API_TOKEN", "dummy_token");
1037 env::set_var("VKTEAMS_BOT_API_URL", "https://dummy.api.com");
1038 }
1039 tempfile::tempdir().expect("Failed to create temp directory")
1040 }
1041
1042 #[test]
1043 fn test_execute_schedule_api_error() {
1044 let cmd = SchedulingCommands::Schedule {
1045 message_type: ScheduleMessageType::Text {
1046 chat_id: "12345@chat".to_string(),
1047 message: "hello".to_string(),
1048 time: None,
1049 cron: None,
1050 interval: None,
1051 max_runs: None,
1052 },
1053 };
1054 let bot = dummy_bot();
1055 let rt = Runtime::new().unwrap();
1056 let res = rt.block_on(cmd.execute(&bot));
1058 assert!(res.is_err());
1059 }
1060
1061 #[test]
1062 fn test_execute_task_action_api_error() {
1063 let cmd = SchedulingCommands::Task {
1064 action: TaskAction::Show {
1065 task_id: "id".to_string(),
1066 },
1067 };
1068 let bot = dummy_bot();
1069 let rt = Runtime::new().unwrap();
1070 let res = rt.block_on(cmd.execute(&bot));
1072 assert!(res.is_err());
1073 }
1074
1075 #[test]
1076 fn test_parse_schedule_args_time_invalid() {
1077 let res = parse_schedule_args(&Some("not-a-time".to_string()), &None, &None);
1078 assert!(res.is_err());
1079 }
1080
1081 #[test]
1082 fn test_parse_schedule_args_cron_invalid() {
1083 let res = parse_schedule_args(&None, &Some("* * * *".to_string()), &None);
1084 assert!(res.is_err());
1085 }
1086
1087 #[test]
1088 fn test_parse_schedule_args_interval_zero() {
1089 let res = parse_schedule_args(&None, &None, &Some(0));
1090 assert!(res.is_err());
1091 }
1092
1093 #[test]
1094 fn test_parse_schedule_args_all_none() {
1095 let res = parse_schedule_args(&None, &None, &None);
1096 assert!(res.is_err());
1097 }
1098
1099 #[tokio::test]
1100 async fn test_execute_schedule_success() {
1101 use crate::scheduler::Scheduler;
1102 use tempfile::tempdir;
1103
1104 set_env_vars();
1105
1106 let temp_dir = tempdir().unwrap();
1108 let mut scheduler = Scheduler::new(Some(temp_dir.path().to_path_buf()))
1109 .await
1110 .unwrap();
1111
1112 let token = std::env::var("VKTEAMS_BOT_API_TOKEN").unwrap();
1114 let url = std::env::var("VKTEAMS_BOT_API_URL").unwrap();
1115 let scheduler_bot = Bot::with_params(&APIVersionUrl::V1, &token, &url).unwrap();
1116 scheduler.set_bot(scheduler_bot);
1117
1118 let task_id = scheduler
1120 .add_task(
1121 TaskType::SendText {
1122 chat_id: "12345@chat".to_string(),
1123 message: "hello".to_string(),
1124 },
1125 ScheduleType::Once(
1126 chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1127 .unwrap()
1128 .with_timezone(&Utc),
1129 ),
1130 Some(1),
1131 )
1132 .await
1133 .unwrap();
1134
1135 assert!(!task_id.is_empty());
1137 let tasks = scheduler.list_tasks().await;
1138 assert_eq!(tasks.len(), 1);
1139 assert_eq!(tasks[0].id, task_id);
1140 assert_eq!(tasks[0].run_count, 0);
1141 assert_eq!(tasks[0].max_runs, Some(1));
1142 assert!(tasks[0].enabled);
1143 }
1144
1145 #[test]
1146 fn test_parse_schedule_args_time_success() {
1147 let res = parse_schedule_args(&Some("2030-01-01T00:00:00Z".to_string()), &None, &None);
1148 assert!(matches!(res, Ok(ScheduleType::Once(_))));
1149 }
1150
1151 #[test]
1152 fn test_parse_schedule_args_cron_success() {
1153 let res = parse_schedule_args(&None, &Some("0 * * * * *".to_string()), &None);
1155 assert!(matches!(res, Ok(ScheduleType::Cron(_))));
1156 }
1157
1158 #[test]
1159 fn test_parse_schedule_args_interval_success() {
1160 let res = parse_schedule_args(&None, &None, &Some(60));
1161 assert!(matches!(res, Ok(ScheduleType::Interval { .. })));
1162 }
1163
1164 #[test]
1165 fn test_execute_scheduler_action_status_success() {
1166 set_env_vars();
1167 let cmd = SchedulingCommands::Scheduler {
1168 action: SchedulerAction::Status,
1169 };
1170 let bot = dummy_bot();
1171 let rt = Runtime::new().unwrap();
1172 let res = rt.block_on(cmd.execute(&bot));
1173 assert!(res.is_ok());
1174 }
1175
1176 #[test]
1177 fn test_execute_scheduler_action_list_success() {
1178 set_env_vars();
1179 let cmd = SchedulingCommands::Scheduler {
1180 action: SchedulerAction::List,
1181 };
1182 let bot = dummy_bot();
1183 let rt = Runtime::new().unwrap();
1184 let res = rt.block_on(cmd.execute(&bot));
1185 assert!(res.is_ok());
1186 }
1187
1188 #[test]
1189 fn test_execute_task_action_show_not_found() {
1190 set_env_vars();
1191 let cmd = SchedulingCommands::Task {
1192 action: TaskAction::Show {
1193 task_id: "nonexistent".to_string(),
1194 },
1195 };
1196 let bot = dummy_bot();
1197 let rt = Runtime::new().unwrap();
1198 let res = rt.block_on(cmd.execute(&bot));
1199 assert!(res.is_err());
1200 }
1201
1202 #[tokio::test]
1203 async fn test_execute_task_action_remove_enable_disable() {
1204 set_env_vars();
1205 let mut scheduler = Scheduler::new(None).await.unwrap();
1206 let task_id = scheduler
1208 .add_task(
1209 TaskType::SendText {
1210 chat_id: "12345@chat".to_string(),
1211 message: "test".to_string(),
1212 },
1213 ScheduleType::Once(Utc::now()),
1214 Some(1),
1215 )
1216 .await
1217 .unwrap();
1218 assert!(scheduler.remove_task(&task_id).await.is_ok());
1220 let task_id = scheduler
1222 .add_task(
1223 TaskType::SendText {
1224 chat_id: "12345@chat".to_string(),
1225 message: "test".to_string(),
1226 },
1227 ScheduleType::Once(Utc::now()),
1228 Some(1),
1229 )
1230 .await
1231 .unwrap();
1232 assert!(scheduler.enable_task(&task_id).await.is_ok());
1234 assert!(scheduler.disable_task(&task_id).await.is_ok());
1236 }
1237
1238 #[test]
1239 fn test_validate_chat_id() {
1240 assert!(validate_chat_id("valid_chat").is_ok());
1241 assert!(validate_chat_id("").is_err());
1242 assert!(validate_chat_id(" ").is_err());
1243 assert!(validate_chat_id(&"x".repeat(101)).is_err());
1244 }
1245
1246 #[test]
1247 fn test_validate_message_content() {
1248 assert!(validate_message_content("Hello world").is_ok());
1249 assert!(validate_message_content("").is_err());
1250 assert!(validate_message_content(" ").is_err());
1251 assert!(validate_message_content(&"x".repeat(10001)).is_err());
1252 }
1253
1254 #[test]
1255 fn test_validate_action_type() {
1256 assert!(validate_action_type("typing").is_ok());
1257 assert!(validate_action_type("upload_photo").is_ok());
1258 assert!(validate_action_type("invalid_action").is_err());
1259 assert!(validate_action_type("").is_err());
1260 assert!(validate_action_type(" ").is_err());
1261 }
1262
1263 #[test]
1264 fn test_validate_max_runs() {
1265 assert!(validate_max_runs(&None).is_ok());
1266 assert!(validate_max_runs(&Some(1)).is_ok());
1267 assert!(validate_max_runs(&Some(100)).is_ok());
1268 assert!(validate_max_runs(&Some(0)).is_err());
1269 assert!(validate_max_runs(&Some(10001)).is_err());
1270 }
1271
1272 #[test]
1273 fn test_validate_task_id() {
1274 assert!(validate_task_id("valid_id").is_ok());
1275 assert!(validate_task_id("").is_err());
1276 assert!(validate_task_id(" ").is_err());
1277 assert!(validate_task_id(&"x".repeat(51)).is_err());
1278 }
1279
1280 #[test]
1281 fn test_validate_scheduler_command() {
1282 let valid_cmd = SchedulingCommands::Schedule {
1283 message_type: ScheduleMessageType::Text {
1284 chat_id: "test_chat".to_string(),
1285 message: "test message".to_string(),
1286 time: Some("2030-01-01T00:00:00Z".to_string()),
1287 cron: None,
1288 interval: None,
1289 max_runs: Some(1),
1290 },
1291 };
1292 assert!(valid_cmd.validate().is_ok());
1293
1294 let invalid_cmd = SchedulingCommands::Schedule {
1295 message_type: ScheduleMessageType::Text {
1296 chat_id: "".to_string(), message: "test message".to_string(),
1298 time: Some("2030-01-01T00:00:00Z".to_string()),
1299 cron: None,
1300 interval: None,
1301 max_runs: Some(1),
1302 },
1303 };
1304 assert!(invalid_cmd.validate().is_err());
1305 }
1306
1307 #[tokio::test]
1308 async fn test_stop_scheduler_daemon_no_running_daemon() {
1309 use std::fs;
1310 use tokio::time::{Duration, timeout};
1311
1312 let temp_dir = std::env::temp_dir();
1314 let stop_file = temp_dir.join("vkteams_scheduler_stop.signal");
1315 let _ = fs::remove_file(&stop_file);
1316
1317 let result = timeout(Duration::from_secs(5), stop_scheduler_daemon()).await;
1320
1321 match result {
1323 Err(_) => {
1324 let _ = fs::remove_file(&stop_file);
1327 }
1328 Ok(scheduler_result) => {
1329 match scheduler_result {
1331 Err(CliError::UnexpectedError(msg)) if msg.contains("30 seconds") => {
1332 }
1334 _ => {
1335 }
1339 }
1340 }
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn test_execute_schedule_structured_json_output() {
1346 use crate::scheduler::Scheduler;
1347 use tempfile::tempdir;
1348
1349 set_env_vars();
1350
1351 let temp_dir = tempdir().unwrap();
1353 let mut scheduler = Scheduler::new(Some(temp_dir.path().to_path_buf()))
1354 .await
1355 .unwrap();
1356
1357 let token = std::env::var("VKTEAMS_BOT_API_TOKEN").unwrap();
1359 let url = std::env::var("VKTEAMS_BOT_API_URL").unwrap();
1360 let scheduler_bot = Bot::with_params(&APIVersionUrl::V1, &token, &url).unwrap();
1361 scheduler.set_bot(scheduler_bot);
1362
1363 let task_id = scheduler
1365 .add_task(
1366 TaskType::SendText {
1367 chat_id: "test_chat".to_string(),
1368 message: "test message".to_string(),
1369 },
1370 ScheduleType::Once(
1371 chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1372 .unwrap()
1373 .with_timezone(&Utc),
1374 ),
1375 Some(1),
1376 )
1377 .await
1378 .unwrap();
1379
1380 assert!(!task_id.is_empty());
1382 let tasks = scheduler.list_tasks().await;
1383 assert_eq!(tasks.len(), 1);
1384 assert_eq!(tasks[0].id, task_id);
1385 assert_eq!(tasks[0].task_type.description(), "Send text to test_chat");
1386 }
1387
1388 #[tokio::test]
1389 async fn test_execute_scheduler_action_structured_status() {
1390 set_env_vars();
1391 let action = SchedulerAction::Status;
1392 let bot = dummy_bot();
1393 let result = execute_scheduler_action_structured(&bot, &action).await;
1394 assert!(result.is_ok());
1395 let response = result.unwrap();
1396 assert!(response.success);
1397 assert!(response.data.is_some());
1398 let data = response.data.unwrap();
1399 assert!(data["daemon_status"].is_string());
1400 assert!(data["total_tasks"].is_number());
1401 assert!(data["enabled_tasks"].is_number());
1402 }
1403
1404 #[tokio::test]
1405 async fn test_execute_scheduler_action_structured_list() {
1406 set_env_vars();
1407 let action = SchedulerAction::List;
1408 let bot = dummy_bot();
1409 let result = execute_scheduler_action_structured(&bot, &action).await;
1410 assert!(result.is_ok());
1411 let response = result.unwrap();
1412 assert!(response.success);
1413 assert!(response.data.is_some());
1414 let data = response.data.unwrap();
1415 assert!(data["tasks"].is_array());
1416 assert!(data["total"].is_number());
1417 }
1418
1419 #[tokio::test]
1420 async fn test_execute_task_action_structured_show_not_found() {
1421 set_env_vars();
1422 let action = TaskAction::Show {
1423 task_id: "nonexistent".to_string(),
1424 };
1425 let bot = dummy_bot();
1426 let result = execute_task_action_structured(&bot, &action).await;
1427 assert!(result.is_err());
1428 }
1429
1430 #[test]
1431 fn test_execute_with_output_method() {
1432 use crate::commands::OutputFormat;
1433
1434 let cmd = SchedulingCommands::Scheduler {
1435 action: SchedulerAction::List,
1436 };
1437 let bot = dummy_bot();
1438 let rt = Runtime::new().unwrap();
1439 set_env_vars();
1440
1441 let result = rt.block_on(cmd.execute_with_output(&bot, &OutputFormat::Json));
1443 assert!(result.is_ok());
1444 }
1445}