Skip to content

Commit 390306c

Browse files
Add watchlist, reply tracking, and reciprocity learning
Engagement intelligence — the CLI now learns from your interactions: Watchlist (persistent, SQLite): - engage watchlist add <username> — track accounts without following - engage watchlist list — show all watched accounts - engage watchlist remove <username> - engage feed checks watchlist FIRST (direct timeline fetch), falls back to cold search only for discovery - Accounts with 10K+ followers auto-added from search results Reply tracking: - Every xmaster reply now logs to engagement_actions with reply_tweet_id - track run checks pending replies for reply-backs (72h window) - got_reply_back status updated: true/false/null(pending) - Reciprocity data now actually populated (was empty before) Schema: watchlist_accounts table + reply_tweet_id column migration. No new crates, no new DB file. All in existing xmaster.db.
1 parent 5018c60 commit 390306c

2 files changed

Lines changed: 148 additions & 11 deletions

File tree

src/commands/engage_recommend.rs

Lines changed: 146 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,95 @@ pub async fn recommend(
224224
// Helpers
225225
// ---------------------------------------------------------------------------
226226

227+
// ---------------------------------------------------------------------------
228+
// Watchlist CRUD
229+
// ---------------------------------------------------------------------------
230+
231+
pub async fn watchlist_add(
232+
ctx: Arc<AppContext>,
233+
format: OutputFormat,
234+
username: &str,
235+
topic: Option<&str>,
236+
) -> Result<(), XmasterError> {
237+
let store = IntelStore::open().map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
238+
let api = crate::providers::xapi::XApi::new(ctx.clone());
239+
240+
// Fetch user info to get ID and follower count
241+
let user = api.get_user_by_username(username).await?;
242+
let followers = user.public_metrics.as_ref().map(|m| m.followers_count as i64).unwrap_or(0);
243+
244+
store.add_watchlist(username, Some(&user.id), topic, followers)
245+
.map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
246+
247+
#[derive(Serialize)]
248+
struct WatchlistAddResult { username: String, user_id: String, followers: i64, topic: Option<String>, status: String }
249+
impl Tableable for WatchlistAddResult {
250+
fn to_table(&self) -> comfy_table::Table {
251+
let mut t = comfy_table::Table::new();
252+
t.set_header(vec!["Field", "Value"]);
253+
t.add_row(vec!["Username", &format!("@{}", self.username)]);
254+
t.add_row(vec!["Followers", &format_followers(self.followers as u64)]);
255+
t.add_row(vec!["Status", &self.status]);
256+
t
257+
}
258+
}
259+
let display = WatchlistAddResult {
260+
username: username.to_string(), user_id: user.id, followers, topic: topic.map(String::from), status: "added".into(),
261+
};
262+
output::render(format, &display, None);
263+
Ok(())
264+
}
265+
266+
pub async fn watchlist_list(format: OutputFormat) -> Result<(), XmasterError> {
267+
let store = IntelStore::open().map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
268+
let entries = store.list_watchlist().map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
269+
270+
if entries.is_empty() {
271+
return Err(XmasterError::NotFound("Watchlist is empty. Add accounts with: xmaster engage watchlist add <username>".into()));
272+
}
273+
274+
#[derive(Serialize)]
275+
struct WatchlistDisplay { accounts: Vec<crate::intel::store::WatchlistEntry> }
276+
impl Tableable for WatchlistDisplay {
277+
fn to_table(&self) -> comfy_table::Table {
278+
let mut t = comfy_table::Table::new();
279+
t.set_header(vec!["Username", "Followers", "Topic"]);
280+
for a in &self.accounts {
281+
t.add_row(vec![
282+
format!("@{}", a.username),
283+
format_followers(a.followers as u64),
284+
a.topic.clone().unwrap_or_default(),
285+
]);
286+
}
287+
t
288+
}
289+
}
290+
291+
output::render(format, &WatchlistDisplay { accounts: entries }, None);
292+
Ok(())
293+
}
294+
295+
pub async fn watchlist_remove(format: OutputFormat, username: &str) -> Result<(), XmasterError> {
296+
let store = IntelStore::open().map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
297+
let removed = store.remove_watchlist(username).map_err(|e| XmasterError::Config(format!("DB error: {e}")))?;
298+
299+
if !removed {
300+
return Err(XmasterError::NotFound(format!("@{username} not in watchlist")));
301+
}
302+
303+
#[derive(Serialize)]
304+
struct RemoveResult { username: String, status: String }
305+
impl Tableable for RemoveResult {
306+
fn to_table(&self) -> comfy_table::Table {
307+
let mut t = comfy_table::Table::new();
308+
t.add_row(vec![&format!("@{} removed from watchlist", self.username)]);
309+
t
310+
}
311+
}
312+
output::render(format, &RemoveResult { username: username.to_string(), status: "removed".into() }, None);
313+
Ok(())
314+
}
315+
227316
// ---------------------------------------------------------------------------
228317
// engage feed — find fresh posts from big accounts to reply to NOW
229318
// ---------------------------------------------------------------------------
@@ -278,21 +367,60 @@ pub async fn feed(
278367
) -> Result<(), XmasterError> {
279368
let api = crate::providers::xapi::XApi::new(ctx.clone());
280369

281-
// Calculate start_time from max_age_mins
370+
// Phase 1: Check watchlist accounts first (saves API search calls)
371+
let mut watchlist_tweets = Vec::new();
372+
if let Ok(store) = IntelStore::open() {
373+
if let Ok(watchlist) = store.list_watchlist() {
374+
for entry in &watchlist {
375+
if let Some(ref uid) = entry.user_id {
376+
let start_time = {
377+
let since = chrono::Utc::now() - chrono::Duration::minutes(max_age_mins as i64);
378+
since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
379+
};
380+
if let Ok(tweets) = api.get_user_tweets_paginated(uid, 5, Some(&start_time), None).await {
381+
for mut t in tweets {
382+
// Inject known follower count from watchlist (avoids missing data)
383+
if t.author_followers.is_none() {
384+
t.author_followers = Some(entry.followers as u64);
385+
}
386+
if t.author_username.is_none() {
387+
t.author_username = Some(entry.username.clone());
388+
}
389+
watchlist_tweets.push(t);
390+
}
391+
}
392+
}
393+
}
394+
}
395+
}
396+
397+
// Phase 2: Cold search for discovery (only if watchlist didn't fill count)
282398
let start_time = {
283399
let now = chrono::Utc::now();
284400
let since = now - chrono::Duration::minutes(max_age_mins as i64);
285401
since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
286402
};
287403

288-
// Search for recent posts on this topic
289-
let tweets = api.search_tweets_paginated(
290-
topic,
291-
"recent",
292-
100.min(count * 5), // fetch more than needed to filter
293-
Some(&start_time),
294-
None,
295-
).await?;
404+
let search_tweets = if watchlist_tweets.len() < count {
405+
api.search_tweets_paginated(
406+
topic,
407+
"recent",
408+
100.min(count * 5),
409+
Some(&start_time),
410+
None,
411+
).await.unwrap_or_default()
412+
} else {
413+
Vec::new()
414+
};
415+
416+
// Combine: watchlist first, then search results
417+
let mut seen_ids = std::collections::HashSet::new();
418+
let mut tweets = Vec::new();
419+
for t in watchlist_tweets.into_iter().chain(search_tweets.into_iter()) {
420+
if seen_ids.insert(t.id.clone()) {
421+
tweets.push(t);
422+
}
423+
}
296424

297425
let now = chrono::Utc::now();
298426
let mut posts: Vec<FeedPost> = Vec::new();
@@ -338,6 +466,15 @@ pub async fn feed(
338466
posts.sort_by_key(|p| p.age_minutes);
339467
posts.truncate(count);
340468

469+
// Auto-add high-value accounts from search to watchlist (silent, never fails)
470+
if let Ok(store) = IntelStore::open() {
471+
for p in &posts {
472+
if p.author_followers >= 10_000 {
473+
let _ = store.add_watchlist(&p.author, None, Some(topic), p.author_followers as i64);
474+
}
475+
}
476+
}
477+
341478
let result = FeedResult {
342479
topic: topic.to_string(),
343480
posts,

src/commands/track.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub async fn track_run(
2727
}
2828

2929
/// Check if targets replied back to our replies.
30-
async fn check_reply_backs(ctx: &AppContext) -> u32 {
30+
async fn check_reply_backs(ctx: &Arc<AppContext>) -> u32 {
3131
let store = match crate::intel::store::IntelStore::open() {
3232
Ok(s) => s,
3333
Err(_) => return 0,
@@ -42,7 +42,7 @@ async fn check_reply_backs(ctx: &AppContext) -> u32 {
4242
return 0;
4343
}
4444

45-
let api = XApi::new(std::sync::Arc::new(ctx.clone()));
45+
let api = XApi::new(ctx.clone());
4646
let mut checked = 0u32;
4747

4848
for pr in &pending {

0 commit comments

Comments
 (0)