tuitbot_core/automation/watchtower/loopback/
mod.rs1pub mod sync;
11
12#[cfg(test)]
13mod tests;
14
15use std::io;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20use crate::storage::DbPool;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct LoopBackEntry {
25 pub tweet_id: String,
26 pub url: String,
27 pub published_at: String,
28 #[serde(rename = "type")]
29 pub content_type: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub status: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub thread_url: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub child_tweet_ids: Option<Vec<String>>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub impressions: Option<i64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub likes: Option<i64>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub retweets: Option<i64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub replies: Option<i64>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub engagement_rate: Option<f64>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub performance_score: Option<f64>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub synced_at: Option<String>,
55}
56
57#[derive(Debug, PartialEq, Eq)]
59pub enum LoopBackResult {
60 Written,
62 AlreadyPresent,
64 SourceNotWritable(String),
66 NodeNotFound,
68 FileNotFound,
70}
71
72pub async fn execute_loopback(
78 pool: &DbPool,
79 node_id: i64,
80 tweet_id: &str,
81 url: &str,
82 content_type: &str,
83) -> LoopBackResult {
84 use crate::storage::watchtower::{get_content_node, get_source_context};
85
86 let node = match get_content_node(pool, node_id).await {
87 Ok(Some(n)) => n,
88 Ok(None) => return LoopBackResult::NodeNotFound,
89 Err(e) => {
90 tracing::warn!(node_id, error = %e, "Loopback: failed to get content node");
91 return LoopBackResult::NodeNotFound;
92 }
93 };
94
95 let source = match get_source_context(pool, node.source_id).await {
96 Ok(Some(s)) => s,
97 Ok(None) => return LoopBackResult::SourceNotWritable("source not found".into()),
98 Err(e) => {
99 tracing::warn!(node_id, error = %e, "Loopback: failed to get source context");
100 return LoopBackResult::SourceNotWritable("db error".into());
101 }
102 };
103
104 if source.source_type != "local_fs" {
105 return LoopBackResult::SourceNotWritable(source.source_type);
106 }
107
108 let base_path = match serde_json::from_str::<serde_json::Value>(&source.config_json)
109 .ok()
110 .and_then(|v| v.get("path")?.as_str().map(String::from))
111 {
112 Some(p) => p,
113 None => return LoopBackResult::SourceNotWritable("no path in config".into()),
114 };
115
116 let expanded = crate::storage::expand_tilde(&base_path);
117 let full_path = std::path::PathBuf::from(expanded).join(&node.relative_path);
118
119 if !full_path.exists() {
120 return LoopBackResult::FileNotFound;
121 }
122
123 let entry = LoopBackEntry {
124 tweet_id: tweet_id.to_string(),
125 url: url.to_string(),
126 published_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
127 content_type: content_type.to_string(),
128 status: Some("posted".to_string()),
129 thread_url: None,
130 child_tweet_ids: None,
131 impressions: None,
132 likes: None,
133 retweets: None,
134 replies: None,
135 engagement_rate: None,
136 performance_score: None,
137 synced_at: None,
138 };
139
140 match write_metadata_to_file(&full_path, &entry) {
141 Ok(true) => LoopBackResult::Written,
142 Ok(false) => LoopBackResult::AlreadyPresent,
143 Err(e) => {
144 tracing::warn!(
145 node_id,
146 path = %full_path.display(),
147 error = %e,
148 "Loopback file write failed"
149 );
150 LoopBackResult::FileNotFound
151 }
152 }
153}
154
155pub async fn execute_loopback_thread(
160 pool: &DbPool,
161 node_id: i64,
162 root_tweet_id: &str,
163 url: &str,
164 child_tweet_ids: Vec<String>,
165) -> LoopBackResult {
166 use crate::storage::watchtower::{get_content_node, get_source_context};
167
168 let node = match get_content_node(pool, node_id).await {
169 Ok(Some(n)) => n,
170 Ok(None) => return LoopBackResult::NodeNotFound,
171 Err(e) => {
172 tracing::warn!(node_id, error = %e, "Loopback thread: failed to get content node");
173 return LoopBackResult::NodeNotFound;
174 }
175 };
176
177 let source = match get_source_context(pool, node.source_id).await {
178 Ok(Some(s)) => s,
179 Ok(None) => return LoopBackResult::SourceNotWritable("source not found".into()),
180 Err(e) => {
181 tracing::warn!(node_id, error = %e, "Loopback thread: failed to get source context");
182 return LoopBackResult::SourceNotWritable("db error".into());
183 }
184 };
185
186 if source.source_type != "local_fs" {
187 return LoopBackResult::SourceNotWritable(source.source_type);
188 }
189
190 let base_path = match serde_json::from_str::<serde_json::Value>(&source.config_json)
191 .ok()
192 .and_then(|v| v.get("path")?.as_str().map(String::from))
193 {
194 Some(p) => p,
195 None => return LoopBackResult::SourceNotWritable("no path in config".into()),
196 };
197
198 let expanded = crate::storage::expand_tilde(&base_path);
199 let full_path = std::path::PathBuf::from(expanded).join(&node.relative_path);
200
201 if !full_path.exists() {
202 return LoopBackResult::FileNotFound;
203 }
204
205 let entry = LoopBackEntry {
206 tweet_id: root_tweet_id.to_string(),
207 url: url.to_string(),
208 published_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
209 content_type: "thread".to_string(),
210 status: Some("posted".to_string()),
211 thread_url: Some(url.to_string()),
212 child_tweet_ids: if child_tweet_ids.is_empty() {
213 None
214 } else {
215 Some(child_tweet_ids)
216 },
217 impressions: None,
218 likes: None,
219 retweets: None,
220 replies: None,
221 engagement_rate: None,
222 performance_score: None,
223 synced_at: None,
224 };
225
226 match write_metadata_to_file(&full_path, &entry) {
227 Ok(true) => LoopBackResult::Written,
228 Ok(false) => LoopBackResult::AlreadyPresent,
229 Err(e) => {
230 tracing::warn!(
231 node_id,
232 path = %full_path.display(),
233 error = %e,
234 "Loopback thread file write failed"
235 );
236 LoopBackResult::FileNotFound
237 }
238 }
239}
240
241#[derive(Debug, Default, Serialize, Deserialize)]
243pub struct TuitbotFrontMatter {
244 #[serde(default)]
245 pub tuitbot: Vec<LoopBackEntry>,
246 #[serde(flatten)]
247 pub other: serde_yaml::Mapping,
248}
249
250pub fn split_front_matter(content: &str) -> (Option<&str>, &str) {
255 if !content.starts_with("---") {
256 return (None, content);
257 }
258
259 let after_open = &content[3..];
261 let after_open = after_open
263 .strip_prefix('\n')
264 .unwrap_or(after_open.strip_prefix("\r\n").unwrap_or(after_open));
265
266 if let Some(close_pos) = after_open.find("\n---") {
267 let yaml = &after_open[..close_pos];
268 let rest_start = close_pos + 4; let body = &after_open[rest_start..];
270 let body = body
272 .strip_prefix('\n')
273 .unwrap_or(body.strip_prefix("\r\n").unwrap_or(body));
274 (Some(yaml), body)
275 } else {
276 (None, content)
278 }
279}
280
281pub fn parse_tuitbot_metadata(content: &str) -> Vec<LoopBackEntry> {
283 let (yaml_str, _) = split_front_matter(content);
284 let yaml_str = match yaml_str {
285 Some(y) => y,
286 None => return Vec::new(),
287 };
288
289 match serde_yaml::from_str::<TuitbotFrontMatter>(yaml_str) {
290 Ok(fm) => fm.tuitbot,
291 Err(_) => Vec::new(),
292 }
293}
294
295pub fn write_metadata_to_file(path: &Path, entry: &LoopBackEntry) -> Result<bool, io::Error> {
302 let content = std::fs::read_to_string(path)?;
303
304 let existing = parse_tuitbot_metadata(&content);
306 if existing.iter().any(|e| e.tweet_id == entry.tweet_id) {
307 return Ok(false);
308 }
309
310 let (yaml_str, body) = split_front_matter(&content);
311
312 let mut fm: TuitbotFrontMatter = match yaml_str {
314 Some(y) => serde_yaml::from_str(y).unwrap_or_default(),
315 None => TuitbotFrontMatter::default(),
316 };
317
318 fm.tuitbot.push(entry.clone());
319
320 serialize_frontmatter_to_file(path, &fm, body)
321}
322
323pub(crate) fn serialize_frontmatter_to_file(
325 path: &Path,
326 fm: &TuitbotFrontMatter,
327 body: &str,
328) -> Result<bool, io::Error> {
329 let yaml_out = serde_yaml::to_string(fm).map_err(io::Error::other)?;
330
331 let mut output = String::with_capacity(yaml_out.len() + body.len() + 10);
332 output.push_str("---\n");
333 output.push_str(&yaml_out);
334 if !yaml_out.ends_with('\n') {
335 output.push('\n');
336 }
337 output.push_str("---\n");
338 output.push_str(body);
339
340 std::fs::write(path, output)?;
341 Ok(true)
342}