#[cfg(test)]
mod tests;
use std::sync::Arc;
use std::time::{Duration, Instant};
use fs2::FileExt;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use tuitbot_core::automation::{
run_posting_queue_with_approval, AnalyticsLoop, ContentLoop, DiscoveryLoop, MentionsLoop,
PostExecutor, TargetLoop, ThreadLoop,
};
use tuitbot_core::config::{Config, OperatingMode};
use super::TickArgs;
use crate::deps::RuntimeDeps;
use crate::output::CliOutput;
#[derive(Serialize)]
struct TickOutput {
success: bool,
tier: String,
schedule_active: bool,
dry_run: bool,
approval_mode: bool,
duration_ms: u64,
loops: LoopResults,
errors: Vec<LoopErrorJson>,
#[serde(skip_serializing_if = "Option::is_none")]
enrichment_tip: Option<String>,
}
#[derive(Serialize)]
struct LoopResults {
analytics: LoopOutcome,
discovery: LoopOutcome,
mentions: LoopOutcome,
target: LoopOutcome,
content: LoopOutcome,
thread: LoopOutcome,
}
#[derive(Serialize)]
#[serde(tag = "status")]
enum LoopOutcome {
#[serde(rename = "completed")]
Completed { detail: String },
#[serde(rename = "skipped")]
Skipped { reason: String },
#[serde(rename = "failed")]
Failed { error: String },
}
#[derive(Serialize)]
struct LoopErrorJson {
loop_name: String,
error: String,
}
#[derive(Debug)]
struct LoopFilter {
analytics: bool,
discovery: bool,
mentions: bool,
target: bool,
content: bool,
thread: bool,
}
impl LoopFilter {
const VALID_NAMES: &'static [&'static str] = &[
"analytics",
"discovery",
"mentions",
"target",
"content",
"thread",
];
fn from_args(args: &TickArgs) -> Result<Self, anyhow::Error> {
match &args.loops {
Some(names) => {
let names: Vec<&str> = names
.iter()
.map(|n| n.trim())
.filter(|n| !n.is_empty())
.collect();
if names.is_empty() {
anyhow::bail!(
"--loops cannot be empty. Valid values: {}",
Self::VALID_NAMES.join(", ")
);
}
for name in &names {
if !Self::VALID_NAMES.contains(name) {
anyhow::bail!(
"unknown loop '{}'; valid values: {}",
name,
Self::VALID_NAMES.join(", ")
);
}
}
Ok(Self {
analytics: names.contains(&"analytics"),
discovery: names.contains(&"discovery"),
mentions: names.contains(&"mentions"),
target: names.contains(&"target"),
content: names.contains(&"content"),
thread: names.contains(&"thread"),
})
}
None => Ok(Self {
analytics: true,
discovery: true,
mentions: true,
target: true,
content: true,
thread: true,
}),
}
}
}
pub async fn execute(config: &Config, args: TickArgs, out: CliOutput) -> anyhow::Result<()> {
let start = Instant::now();
let filter = LoopFilter::from_args(&args)?;
let lock_path = dirs::home_dir()
.unwrap_or_default()
.join(".tuitbot")
.join("tuitbot.lock");
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&lock_path)?;
if lock_file.try_lock_exclusive().is_err() {
anyhow::bail!(
"Another tuitbot tick process is running (lock: {})",
lock_path.display()
);
}
let mut deps = RuntimeDeps::init(config, args.dry_run).await?;
let schedule_active = if args.ignore_schedule {
true
} else {
deps.active_schedule
.as_ref()
.map_or(true, |s| s.is_active())
};
if args.dry_run && !out.is_json() {
eprintln!("Dry run: showing what the bot would do. No posts will be made.");
eprintln!();
}
if !schedule_active {
let output = TickOutput {
success: true,
tier: deps.tier.to_string(),
schedule_active: false,
dry_run: args.dry_run,
approval_mode: config.approval_mode,
duration_ms: start.elapsed().as_millis() as u64,
loops: LoopResults {
analytics: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
discovery: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
mentions: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
target: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
content: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
thread: LoopOutcome::Skipped {
reason: "outside active hours".to_string(),
},
},
errors: Vec::new(),
enrichment_tip: None,
};
print_output(&output, out);
return Ok(());
}
let cancel = CancellationToken::new();
let post_rx = deps.post_rx.take().expect("post_rx not yet consumed");
let queue_cancel = cancel.clone();
let queue_handle = tokio::spawn({
let executor = deps.post_executor.clone() as Arc<dyn PostExecutor>;
let approval_queue = deps.approval_queue.clone();
async move {
run_posting_queue_with_approval(
post_rx,
executor,
approval_queue,
Duration::ZERO,
Duration::ZERO,
None,
queue_cancel,
)
.await;
}
});
let mut errors: Vec<LoopErrorJson> = Vec::new();
let is_composer = config.mode == OperatingMode::Composer;
let analytics_outcome = run_analytics(&deps, &filter, config, &mut errors).await;
let discovery_outcome = if is_composer {
LoopOutcome::Skipped {
reason: "disabled in composer mode".to_string(),
}
} else {
run_discovery(&deps, &filter, config, &mut errors).await
};
let mentions_outcome = if is_composer {
LoopOutcome::Skipped {
reason: "disabled in composer mode".to_string(),
}
} else {
run_mentions(&deps, &filter, config, &mut errors).await
};
let target_outcome = if is_composer {
LoopOutcome::Skipped {
reason: "disabled in composer mode".to_string(),
}
} else {
run_target(&deps, &filter, config, &mut errors).await
};
let content_outcome = if is_composer {
LoopOutcome::Skipped {
reason: "disabled in composer mode".to_string(),
}
} else {
run_content(&deps, &filter, config, &mut errors).await
};
let thread_outcome = if is_composer {
LoopOutcome::Skipped {
reason: "disabled in composer mode".to_string(),
}
} else {
run_thread(&deps, &filter, config, &mut errors).await
};
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(30), queue_handle).await;
deps.pool.close().await;
let enrichment_tip = compute_enrichment_tip(config);
let output = TickOutput {
success: errors.is_empty(),
tier: deps.tier.to_string(),
schedule_active,
dry_run: args.dry_run,
approval_mode: config.approval_mode,
duration_ms: start.elapsed().as_millis() as u64,
loops: LoopResults {
analytics: analytics_outcome,
discovery: discovery_outcome,
mentions: mentions_outcome,
target: target_outcome,
content: content_outcome,
thread: thread_outcome,
},
errors,
enrichment_tip,
};
print_output(&output, out);
if !output.success {
if out.is_json() {
std::process::exit(1);
}
anyhow::bail!("tick failed: {} error(s)", output.errors.len());
}
Ok(())
}
async fn run_analytics(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.analytics {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
if !deps.capabilities.mentions {
let reason = if config.x_api.provider_backend == "scraper" {
"analytics not supported in scraper mode".to_string()
} else {
"requires Basic/Pro tier".to_string()
};
return LoopOutcome::Skipped { reason };
}
let analytics_loop = AnalyticsLoop::new(
deps.profile_adapter.clone(),
deps.profile_adapter.clone(),
deps.analytics_storage.clone(),
);
match analytics_loop.run_iteration().await {
Ok(summary) => LoopOutcome::Completed {
detail: format!(
"followers={}, replies_measured={}, tweets_measured={}",
summary.follower_count, summary.replies_measured, summary.tweets_measured
),
},
Err(e) => {
let msg = e.to_string();
errors.push(LoopErrorJson {
loop_name: "analytics".to_string(),
error: msg.clone(),
});
LoopOutcome::Failed { error: msg }
}
}
}
async fn run_discovery(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.discovery {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
if !deps.capabilities.discovery {
let reason = if config.x_api.provider_backend == "scraper" {
"search not supported in scraper mode".to_string()
} else {
"requires Basic/Pro tier".to_string()
};
return LoopOutcome::Skipped { reason };
}
if deps.keywords.is_empty() {
return LoopOutcome::Skipped {
reason: "no keywords configured".to_string(),
};
}
let discovery_loop = DiscoveryLoop::new(
deps.searcher.clone(),
deps.scorer.clone(),
deps.reply_gen.clone(),
deps.safety.clone(),
deps.loop_storage.clone(),
deps.post_sender.clone(),
deps.keywords.clone(),
config.scoring.threshold as f32,
deps.target_loop_config.dry_run,
);
match discovery_loop.run_once(None).await {
Ok((_results, summary)) => LoopOutcome::Completed {
detail: format!(
"found={}, qualifying={}, replied={}, skipped={}, failed={}",
summary.tweets_found,
summary.qualifying,
summary.replied,
summary.skipped,
summary.failed
),
},
Err(e) => {
let msg = e.to_string();
errors.push(LoopErrorJson {
loop_name: "discovery".to_string(),
error: msg.clone(),
});
LoopOutcome::Failed { error: msg }
}
}
}
async fn run_mentions(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.mentions {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
if !deps.capabilities.mentions {
let reason = if config.x_api.provider_backend == "scraper" {
"mentions not supported in scraper mode".to_string()
} else {
"requires Basic/Pro tier".to_string()
};
return LoopOutcome::Skipped { reason };
}
let mentions_loop = MentionsLoop::new(
deps.mentions_fetcher.clone(),
deps.reply_gen.clone(),
deps.safety.clone(),
deps.post_sender.clone(),
deps.target_loop_config.dry_run,
);
let storage: Arc<dyn tuitbot_core::automation::LoopStorage> = deps.loop_storage.clone();
match mentions_loop.run_once(None, None, &storage).await {
Ok((results, _since_id)) => {
let replied = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::MentionResult::Replied { .. }))
.count();
let skipped = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::MentionResult::Skipped { .. }))
.count();
let failed = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::MentionResult::Failed { .. }))
.count();
LoopOutcome::Completed {
detail: format!(
"total={}, replied={}, skipped={}, failed={}",
results.len(),
replied,
skipped,
failed
),
}
}
Err(e) => {
let msg = e.to_string();
errors.push(LoopErrorJson {
loop_name: "mentions".to_string(),
error: msg.clone(),
});
LoopOutcome::Failed { error: msg }
}
}
}
async fn run_target(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.target {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
if !deps.capabilities.mentions {
let reason = if config.x_api.provider_backend == "scraper" {
"target monitoring not supported in scraper mode".to_string()
} else {
"requires Basic/Pro tier".to_string()
};
return LoopOutcome::Skipped { reason };
}
if deps.target_loop_config.accounts.is_empty() {
return LoopOutcome::Skipped {
reason: "no target accounts configured".to_string(),
};
}
let target_loop = TargetLoop::new(
deps.target_adapter.clone(),
deps.target_adapter.clone(),
deps.reply_gen.clone(),
deps.safety.clone(),
deps.target_storage.clone(),
deps.post_sender.clone(),
deps.target_loop_config.clone(),
);
match target_loop.run_iteration().await {
Ok(results) => {
let replied = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::TargetResult::Replied { .. }))
.count();
let skipped = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::TargetResult::Skipped { .. }))
.count();
let failed = results
.iter()
.filter(|r| matches!(r, tuitbot_core::automation::TargetResult::Failed { .. }))
.count();
LoopOutcome::Completed {
detail: format!(
"total={}, replied={}, skipped={}, failed={}",
results.len(),
replied,
skipped,
failed
),
}
}
Err(e) => {
let msg = e.to_string();
errors.push(LoopErrorJson {
loop_name: "target".to_string(),
error: msg.clone(),
});
LoopOutcome::Failed { error: msg }
}
}
}
async fn run_content(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.content {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
let effective_topics = config.business.effective_industry_topics();
if effective_topics.is_empty() {
return LoopOutcome::Skipped {
reason: "no industry topics configured".to_string(),
};
}
let content_loop = ContentLoop::new(
deps.tweet_gen.clone(),
deps.content_safety.clone(),
deps.content_storage.clone(),
effective_topics.to_vec(),
config.intervals.content_post_window_seconds,
deps.target_loop_config.dry_run,
)
.with_topic_scorer(deps.topic_scorer.clone())
.with_thread_poster(deps.thread_poster.clone());
match content_loop.run_once(None).await {
tuitbot_core::automation::ContentResult::Posted { topic, content } => {
LoopOutcome::Completed {
detail: format!("topic='{}', chars={}", topic, content.len()),
}
}
tuitbot_core::automation::ContentResult::TooSoon {
elapsed_secs,
window_secs,
} => LoopOutcome::Skipped {
reason: format!(
"too soon since last tweet ({}s / {}s window)",
elapsed_secs, window_secs
),
},
tuitbot_core::automation::ContentResult::RateLimited => LoopOutcome::Skipped {
reason: "daily tweet limit reached".to_string(),
},
tuitbot_core::automation::ContentResult::NoTopics => LoopOutcome::Skipped {
reason: "no topics configured".to_string(),
},
tuitbot_core::automation::ContentResult::Failed { error } => {
errors.push(LoopErrorJson {
loop_name: "content".to_string(),
error: error.clone(),
});
LoopOutcome::Failed { error }
}
}
}
async fn run_thread(
deps: &RuntimeDeps,
filter: &LoopFilter,
config: &Config,
errors: &mut Vec<LoopErrorJson>,
) -> LoopOutcome {
if !filter.thread {
return LoopOutcome::Skipped {
reason: "not in --loops filter".to_string(),
};
}
let effective_topics = config.business.effective_industry_topics();
if effective_topics.is_empty() {
return LoopOutcome::Skipped {
reason: "no industry topics configured".to_string(),
};
}
let thread_loop = ThreadLoop::new(
deps.thread_gen.clone(),
deps.content_safety.clone(),
deps.content_storage.clone(),
deps.thread_poster.clone(),
effective_topics.to_vec(),
config.intervals.thread_interval_seconds,
deps.target_loop_config.dry_run,
);
match thread_loop.run_once(None, None).await {
tuitbot_core::automation::ThreadResult::Posted {
topic, tweet_count, ..
} => LoopOutcome::Completed {
detail: format!("topic='{}', tweets={}", topic, tweet_count),
},
tuitbot_core::automation::ThreadResult::TooSoon {
elapsed_secs,
interval_secs,
} => LoopOutcome::Skipped {
reason: format!(
"too soon since last thread ({}s / {}s interval)",
elapsed_secs, interval_secs
),
},
tuitbot_core::automation::ThreadResult::RateLimited => LoopOutcome::Skipped {
reason: "weekly thread limit reached".to_string(),
},
tuitbot_core::automation::ThreadResult::NoTopics => LoopOutcome::Skipped {
reason: "no topics configured".to_string(),
},
tuitbot_core::automation::ThreadResult::ValidationFailed { error } => {
errors.push(LoopErrorJson {
loop_name: "thread".to_string(),
error: error.clone(),
});
LoopOutcome::Failed { error }
}
tuitbot_core::automation::ThreadResult::PartialFailure {
tweets_posted,
total_tweets,
error,
..
} => {
let detail = format!(
"partial: {}/{} tweets posted, error: {}",
tweets_posted, total_tweets, error
);
errors.push(LoopErrorJson {
loop_name: "thread".to_string(),
error: detail.clone(),
});
LoopOutcome::Failed { error: detail }
}
tuitbot_core::automation::ThreadResult::Failed { error } => {
errors.push(LoopErrorJson {
loop_name: "thread".to_string(),
error: error.clone(),
});
LoopOutcome::Failed { error }
}
}
}
fn print_output(output: &TickOutput, out: CliOutput) {
if out.is_json() {
let _ = out.json(output);
} else if !out.quiet {
print_text_output(output);
}
}
fn print_text_output(output: &TickOutput) {
eprintln!(
"tuitbot tick tier={} schedule={} dry_run={} approval_mode={} duration={}ms",
output.tier,
if output.schedule_active {
"active"
} else {
"inactive"
},
output.dry_run,
output.approval_mode,
output.duration_ms,
);
eprintln!();
let loop_entries = [
("analytics", &output.loops.analytics),
("discovery", &output.loops.discovery),
("mentions", &output.loops.mentions),
("target", &output.loops.target),
("content", &output.loops.content),
("thread", &output.loops.thread),
];
for (name, outcome) in &loop_entries {
let (status, detail) = match outcome {
LoopOutcome::Completed { detail } => ("OK", detail.as_str()),
LoopOutcome::Skipped { reason } => ("SKIP", reason.as_str()),
LoopOutcome::Failed { error } => ("FAIL", error.as_str()),
};
eprintln!(" {:<12} {:<6} {}", name, status, detail);
}
if !output.errors.is_empty() {
eprintln!();
eprintln!("Errors:");
for err in &output.errors {
eprintln!(" {}: {}", err.loop_name, err.error);
}
}
eprintln!();
eprintln!(
"Result: {}",
if output.success { "success" } else { "failure" }
);
if output.dry_run && output.success {
eprintln!();
if let Some(tip) = &output.enrichment_tip {
eprintln!("Tip: {tip}");
} else {
eprintln!("Tip: Use `tuitbot settings` to fine-tune your configuration");
}
}
}
fn compute_enrichment_tip(config: &Config) -> Option<String> {
let completeness = config.profile_completeness();
if completeness.is_fully_enriched() {
return None;
}
completeness.next_incomplete().map(|stage| {
format!(
"Run `tuitbot settings enrich` to configure {} \u{2014} {}",
stage.label().to_lowercase(),
stage.description()
)
})
}