@@ -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,
0 commit comments