1use crate::{client::WebClient, error::WebToolError};
7use riglr_core::provider::ApplicationContext;
8use riglr_macros::tool;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::env;
13use tracing::{debug, info};
14
15const TWEETSCOUT_API_KEY_ENV: &str = "TWEETSCOUT_API_KEY";
17
18#[derive(Debug, Clone)]
20pub struct TweetScoutConfig {
21 pub base_url: String,
23 pub api_key: Option<String>,
25 pub rate_limit_per_minute: u32,
27 pub request_timeout: u64,
29}
30
31impl Default for TweetScoutConfig {
32 fn default() -> Self {
33 Self {
34 base_url: "https://api.tweetscout.io/api".to_string(),
35 api_key: env::var(TWEETSCOUT_API_KEY_ENV).ok(),
36 rate_limit_per_minute: 60,
37 request_timeout: 30,
38 }
39 }
40}
41
42fn get_api_key_from_context(context: &ApplicationContext) -> Result<String, WebToolError> {
44 context
45 .config
46 .providers
47 .tweetscout_api_key
48 .clone()
49 .ok_or_else(|| {
50 WebToolError::Config(
51 "TweetScout API key not configured. Set TWEETSCOUT_API_KEY in your environment."
52 .to_string(),
53 )
54 })
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct AccountInfo {
60 pub id: Option<String>,
62 pub name: Option<String>,
64 pub screen_name: Option<String>,
66 pub description: Option<String>,
68 pub avatar: Option<String>,
70 pub banner: Option<String>,
72 pub followers_count: Option<i64>,
74 pub friends_count: Option<i64>,
76 pub statuses_count: Option<i64>,
78 pub register_date: Option<String>,
80 pub verified: Option<bool>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
86pub struct ScoreResponse {
87 pub score: f64,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
93pub struct Account {
94 pub id: Option<String>,
96 pub name: Option<String>,
98 #[serde(rename = "screeName")]
100 pub screen_name: Option<String>,
101 pub description: Option<String>,
103 pub avatar: Option<String>,
105 pub banner: Option<String>,
107 #[serde(rename = "followersCount")]
109 pub followers_count: Option<i64>,
110 #[serde(rename = "friendsCount")]
112 pub friends_count: Option<i64>,
113 pub statuses_count: Option<i64>,
115 #[serde(rename = "registerDate")]
117 pub register_date: Option<String>,
118 pub verified: Option<bool>,
120 pub score: Option<f64>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
126pub struct ErrorResponse {
127 pub message: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133pub struct AccountAnalysis {
134 pub username: String,
136 pub info: AccountInfo,
138 pub credibility_score: f64,
140 pub score_level: ScoreLevel,
142 pub account_age_days: Option<i64>,
144 pub avg_tweets_per_day: Option<f64>,
146 pub follower_ratio: Option<f64>,
148 pub engagement: EngagementMetrics,
150 pub risk_indicators: Vec<String>,
152 pub assessment: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
158pub enum ScoreLevel {
159 #[serde(rename = "excellent")]
161 Excellent,
162 #[serde(rename = "good")]
164 Good,
165 #[serde(rename = "fair")]
167 Fair,
168 #[serde(rename = "poor")]
170 Poor,
171 #[serde(rename = "very_poor")]
173 VeryPoor,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
178pub struct EngagementMetrics {
179 pub followers: i64,
181 pub following: i64,
183 pub posts: i64,
185 pub engagement_rate: f64,
187 pub likely_bot: bool,
189 pub likely_spam: bool,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195pub struct SocialNetworkAnalysis {
196 pub username: String,
198 pub top_followers: Vec<ScoredAccount>,
200 pub top_friends: Vec<ScoredAccount>,
202 pub avg_follower_score: f64,
204 pub avg_friend_score: f64,
206 pub network_quality: NetworkQuality,
208 pub key_influencers: Vec<String>,
210 pub assessment: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
216pub struct ScoredAccount {
217 pub username: String,
219 pub name: String,
221 pub followers: i64,
223 pub score: f64,
225 pub verified: bool,
227 pub influence_level: String,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
233pub enum NetworkQuality {
234 #[serde(rename = "high")]
236 High,
237 #[serde(rename = "medium")]
239 Medium,
240 #[serde(rename = "low")]
242 Low,
243 #[serde(rename = "suspicious")]
245 Suspicious,
246}
247
248#[tool]
250pub async fn get_account_info(
251 context: &ApplicationContext,
252 username: String,
253) -> crate::error::Result<AccountInfo> {
254 debug!("Fetching account info for: {}", username);
255
256 let config = TweetScoutConfig::default();
257 let client = WebClient::default();
258
259 let api_key = get_api_key_from_context(context)?;
260
261 let url = format!("{}/info/{}", config.base_url, username);
262
263 let mut headers = HashMap::new();
264 headers.insert("ApiKey".to_string(), api_key);
265
266 info!("Requesting account info from TweetScout for: {}", username);
267
268 let response_text = client
269 .get_with_headers(&url, headers)
270 .await
271 .map_err(|e| WebToolError::Network(format!("Failed to fetch account info: {}", e)))?;
272
273 let info: AccountInfo = serde_json::from_str(&response_text).map_err(|e| {
274 WebToolError::Parsing(format!("Failed to parse TweetScout response: {}", e))
275 })?;
276
277 info!(
278 "Successfully fetched info for @{} - Followers: {:?}, Verified: {:?}",
279 username, info.followers_count, info.verified
280 );
281
282 Ok(info)
283}
284
285#[tool]
288pub async fn get_account_score(
289 context: &ApplicationContext,
290 username: String,
291) -> crate::error::Result<ScoreResponse> {
292 debug!("Fetching credibility score for: {}", username);
293
294 let config = TweetScoutConfig::default();
295 let client = WebClient::default();
296
297 let api_key = get_api_key_from_context(context)?;
298
299 let url = format!("{}/score/{}", config.base_url, username);
300
301 let mut headers = HashMap::new();
302 headers.insert("ApiKey".to_string(), api_key);
303
304 info!(
305 "Requesting credibility score from TweetScout for: {}",
306 username
307 );
308
309 let response_text = client
310 .get_with_headers(&url, headers)
311 .await
312 .map_err(|e| WebToolError::Network(format!("Failed to fetch score: {}", e)))?;
313
314 let score: ScoreResponse = serde_json::from_str(&response_text)
315 .map_err(|e| WebToolError::Parsing(format!("Failed to parse score response: {}", e)))?;
316
317 info!(
318 "Successfully fetched score for @{}: {:.1}/100",
319 username, score.score
320 );
321
322 Ok(score)
323}
324
325#[tool]
327pub async fn get_top_followers(
328 context: &ApplicationContext,
329 username: String,
330) -> crate::error::Result<Vec<Account>> {
331 debug!("Fetching top followers for: {}", username);
332
333 let config = TweetScoutConfig::default();
334 let client = WebClient::default();
335
336 let api_key = get_api_key_from_context(context)?;
337
338 let url = format!("{}/top-followers/{}", config.base_url, username);
339
340 let mut headers = HashMap::new();
341 headers.insert("ApiKey".to_string(), api_key);
342
343 info!("Requesting top followers from TweetScout for: {}", username);
344
345 let response_text = client
346 .get_with_headers(&url, headers)
347 .await
348 .map_err(|e| WebToolError::Network(format!("Failed to fetch followers: {}", e)))?;
349
350 let followers: Vec<Account> = serde_json::from_str(&response_text)
351 .map_err(|e| WebToolError::Parsing(format!("Failed to parse followers response: {}", e)))?;
352
353 info!(
354 "Successfully fetched {} top followers for @{}",
355 followers.len(),
356 username
357 );
358
359 Ok(followers)
360}
361
362#[tool]
364pub async fn get_top_friends(
365 context: &ApplicationContext,
366 username: String,
367) -> crate::error::Result<Vec<Account>> {
368 debug!("Fetching top friends for: {}", username);
369
370 let config = TweetScoutConfig::default();
371 let client = WebClient::default();
372
373 let api_key = get_api_key_from_context(context)?;
374
375 let url = format!("{}/top-friends/{}", config.base_url, username);
376
377 let mut headers = HashMap::new();
378 headers.insert("ApiKey".to_string(), api_key);
379
380 info!("Requesting top friends from TweetScout for: {}", username);
381
382 let response_text = client
383 .get_with_headers(&url, headers)
384 .await
385 .map_err(|e| WebToolError::Network(format!("Failed to fetch friends: {}", e)))?;
386
387 let friends: Vec<Account> = serde_json::from_str(&response_text)
388 .map_err(|e| WebToolError::Parsing(format!("Failed to parse friends response: {}", e)))?;
389
390 info!(
391 "Successfully fetched {} top friends for @{}",
392 friends.len(),
393 username
394 );
395
396 Ok(friends)
397}
398
399#[tool]
402pub async fn analyze_account(
403 context: &ApplicationContext,
404 username: String,
405) -> crate::error::Result<AccountAnalysis> {
406 debug!("Performing comprehensive analysis for: {}", username);
407
408 let info = get_account_info(context, username.clone()).await?;
410 let score_resp = get_account_score(context, username.clone()).await?;
411
412 let account_age_days = calculate_account_age(&info);
413 let avg_tweets_per_day = calculate_avg_tweets_per_day(&info, account_age_days);
414 let follower_ratio = calculate_follower_ratio(&info);
415 let score_level = determine_score_level(score_resp.score);
416 let engagement = build_engagement_metrics(&info, score_resp.score);
417 let risk_indicators =
418 build_risk_indicators(&info, &engagement, follower_ratio, score_resp.score);
419 let assessment = build_assessment(&username, score_resp.score, &score_level);
420
421 Ok(AccountAnalysis {
422 username,
423 info,
424 credibility_score: score_resp.score,
425 score_level,
426 account_age_days,
427 avg_tweets_per_day,
428 follower_ratio,
429 engagement,
430 risk_indicators,
431 assessment,
432 })
433}
434
435fn calculate_account_age(info: &AccountInfo) -> Option<i64> {
437 info.register_date.as_ref().and({
438 None
441 })
442}
443
444fn calculate_avg_tweets_per_day(info: &AccountInfo, account_age_days: Option<i64>) -> Option<f64> {
446 if let (Some(_tweets), Some(_age)) = (info.statuses_count, account_age_days) {
447 None } else {
449 None
450 }
451}
452
453fn calculate_follower_ratio(info: &AccountInfo) -> Option<f64> {
455 if let (Some(followers), Some(following)) = (info.followers_count, info.friends_count) {
456 if following > 0 {
457 Some(followers as f64 / following as f64)
458 } else {
459 None
460 }
461 } else {
462 None
463 }
464}
465
466fn determine_score_level(score: f64) -> ScoreLevel {
468 match score as i32 {
469 80..=100 => ScoreLevel::Excellent,
470 60..=79 => ScoreLevel::Good,
471 40..=59 => ScoreLevel::Fair,
472 20..=39 => ScoreLevel::Poor,
473 _ => ScoreLevel::VeryPoor,
474 }
475}
476
477fn build_engagement_metrics(info: &AccountInfo, score: f64) -> EngagementMetrics {
479 let followers = info.followers_count.unwrap_or(0);
480 let following = info.friends_count.unwrap_or(0);
481 let posts = info.statuses_count.unwrap_or(0);
482
483 let likely_bot = score < 30.0
485 || (following > followers * 10 && followers < 100)
486 || (posts > 100000 && followers < 1000);
487
488 let likely_spam = score < 20.0 || (following > 5000 && followers < 100);
489
490 let engagement_rate = if posts > 0 {
491 ((followers + following) as f64 / posts as f64).min(100.0)
492 } else {
493 0.0
494 };
495
496 EngagementMetrics {
497 followers,
498 following,
499 posts,
500 engagement_rate,
501 likely_bot,
502 likely_spam,
503 }
504}
505
506fn build_risk_indicators(
508 info: &AccountInfo,
509 engagement: &EngagementMetrics,
510 follower_ratio: Option<f64>,
511 score: f64,
512) -> Vec<String> {
513 let mut risk_indicators = Vec::new();
514
515 if score < 40.0 {
516 risk_indicators.push("Low credibility score".to_string());
517 }
518
519 if engagement.likely_bot {
520 risk_indicators.push("Likely bot account".to_string());
521 }
522
523 if engagement.likely_spam {
524 risk_indicators.push("Likely spam account".to_string());
525 }
526
527 if info.verified != Some(true) && engagement.followers > 10000 {
528 risk_indicators.push("Large unverified account".to_string());
529 }
530
531 if let Some(ratio) = follower_ratio {
532 if ratio < 0.1 && engagement.followers < 1000 {
533 risk_indicators.push("Very low follower ratio".to_string());
534 }
535 }
536
537 if engagement.posts == 0 {
538 risk_indicators.push("No posts/tweets".to_string());
539 }
540
541 risk_indicators
542}
543
544fn build_assessment(username: &str, score: f64, score_level: &ScoreLevel) -> String {
546 match score_level {
547 ScoreLevel::Excellent => format!(
548 "@{} has excellent credibility ({:.1}/100). This appears to be a highly trustworthy account.",
549 username, score
550 ),
551 ScoreLevel::Good => format!(
552 "@{} has good credibility ({:.1}/100). This account appears legitimate and trustworthy.",
553 username, score
554 ),
555 ScoreLevel::Fair => format!(
556 "@{} has fair credibility ({:.1}/100). Exercise some caution when engaging with this account.",
557 username, score
558 ),
559 ScoreLevel::Poor => format!(
560 "@{} has poor credibility ({:.1}/100). Be cautious - this account shows concerning patterns.",
561 username, score
562 ),
563 ScoreLevel::VeryPoor => format!(
564 "@{} has very poor credibility ({:.1}/100). High risk - avoid engagement with this account.",
565 username, score
566 ),
567 }
568}
569
570#[tool]
573pub async fn analyze_social_network(
574 context: &ApplicationContext,
575 username: String,
576) -> crate::error::Result<SocialNetworkAnalysis> {
577 debug!("Analyzing social network for: {}", username);
578
579 let followers = get_top_followers(context, username.clone()).await?;
581 let friends = get_top_friends(context, username.clone()).await?;
582
583 let top_followers: Vec<ScoredAccount> = followers
585 .iter()
586 .map(|acc| ScoredAccount {
587 username: acc.screen_name.clone().unwrap_or_default(),
588 name: acc.name.clone().unwrap_or_default(),
589 followers: acc.followers_count.unwrap_or(0),
590 score: acc.score.unwrap_or(0.0),
591 verified: acc.verified.unwrap_or(false),
592 influence_level: classify_influence(acc.followers_count.unwrap_or(0)),
593 })
594 .collect();
595
596 let top_friends: Vec<ScoredAccount> = friends
597 .iter()
598 .map(|acc| ScoredAccount {
599 username: acc.screen_name.clone().unwrap_or_default(),
600 name: acc.name.clone().unwrap_or_default(),
601 followers: acc.followers_count.unwrap_or(0),
602 score: acc.score.unwrap_or(0.0),
603 verified: acc.verified.unwrap_or(false),
604 influence_level: classify_influence(acc.followers_count.unwrap_or(0)),
605 })
606 .collect();
607
608 let avg_follower_score = if !top_followers.is_empty() {
610 top_followers.iter().map(|a| a.score).sum::<f64>() / top_followers.len() as f64
611 } else {
612 0.0
613 };
614
615 let avg_friend_score = if !top_friends.is_empty() {
616 top_friends.iter().map(|a| a.score).sum::<f64>() / top_friends.len() as f64
617 } else {
618 0.0
619 };
620
621 let mut key_influencers: Vec<String> = top_followers
623 .iter()
624 .chain(top_friends.iter())
625 .filter(|acc| acc.followers > 10000 && acc.score > 50.0)
626 .map(|acc| format!("@{}", acc.username))
627 .collect();
628 key_influencers.dedup();
629 key_influencers.truncate(5); let avg_network_score = (avg_follower_score + avg_friend_score) / 2.0;
633 let network_quality = if avg_network_score > 70.0 {
634 NetworkQuality::High
635 } else if avg_network_score > 50.0 {
636 NetworkQuality::Medium
637 } else if avg_network_score > 30.0 {
638 NetworkQuality::Low
639 } else {
640 NetworkQuality::Suspicious
641 };
642
643 let assessment = match network_quality {
645 NetworkQuality::High => format!(
646 "@{} has a high-quality network with an average score of {:.1}. Strong connections with credible accounts.",
647 username, avg_network_score
648 ),
649 NetworkQuality::Medium => format!(
650 "@{} has a medium-quality network with an average score of {:.1}. Mixed credibility in connections.",
651 username, avg_network_score
652 ),
653 NetworkQuality::Low => format!(
654 "@{} has a low-quality network with an average score of {:.1}. Many connections show poor credibility.",
655 username, avg_network_score
656 ),
657 NetworkQuality::Suspicious => format!(
658 "@{} has a suspicious network with an average score of {:.1}. High risk of bot/spam connections.",
659 username, avg_network_score
660 ),
661 };
662
663 Ok(SocialNetworkAnalysis {
664 username,
665 top_followers,
666 top_friends,
667 avg_follower_score,
668 avg_friend_score,
669 network_quality,
670 key_influencers,
671 assessment,
672 })
673}
674
675fn classify_influence(followers: i64) -> String {
677 match followers {
678 f if f >= 1000000 => "Mega Influencer".to_string(),
679 f if f >= 100000 => "Macro Influencer".to_string(),
680 f if f >= 10000 => "Mid-tier Influencer".to_string(),
681 f if f >= 1000 => "Micro Influencer".to_string(),
682 _ => "Regular User".to_string(),
683 }
684}
685
686#[tool]
689pub async fn is_account_credible(
690 context: &ApplicationContext,
691 username: String,
692 threshold: Option<f64>,
693) -> crate::error::Result<CredibilityCheck> {
694 debug!("Performing quick credibility check for: {}", username);
695
696 let threshold = threshold.unwrap_or(50.0); let score_resp = get_account_score(context, username.clone()).await?;
698
699 let is_credible = score_resp.score >= threshold;
700
701 let verdict = if score_resp.score >= 80.0 {
702 "HIGHLY CREDIBLE"
703 } else if score_resp.score >= 60.0 {
704 "CREDIBLE"
705 } else if score_resp.score >= 40.0 {
706 "QUESTIONABLE"
707 } else if score_resp.score >= 20.0 {
708 "LOW CREDIBILITY"
709 } else {
710 "NOT CREDIBLE"
711 };
712
713 let recommendation = if is_credible {
714 format!(
715 "@{} meets credibility threshold ({:.1}/{:.1}). Safe to engage.",
716 username, score_resp.score, threshold
717 )
718 } else {
719 format!(
720 "@{} below credibility threshold ({:.1}/{:.1}). Exercise caution.",
721 username, score_resp.score, threshold
722 )
723 };
724
725 Ok(CredibilityCheck {
726 username,
727 score: score_resp.score,
728 threshold,
729 is_credible,
730 verdict: verdict.to_string(),
731 recommendation,
732 })
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
737pub struct CredibilityCheck {
738 pub username: String,
740 pub score: f64,
742 pub threshold: f64,
744 pub is_credible: bool,
746 pub verdict: String,
748 pub recommendation: String,
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn test_tweetscout_config_default() {
758 let config = TweetScoutConfig::default();
759 assert_eq!(config.base_url, "https://api.tweetscout.io/api");
760 assert_eq!(config.rate_limit_per_minute, 60);
761 assert_eq!(config.request_timeout, 30);
762 }
763
764 #[test]
765 fn test_score_level_serialization() {
766 let level = ScoreLevel::Good;
767 let json = serde_json::to_string(&level).unwrap();
768 assert_eq!(json, "\"good\"");
769
770 let level: ScoreLevel = serde_json::from_str("\"excellent\"").unwrap();
771 assert!(matches!(level, ScoreLevel::Excellent));
772 }
773
774 #[test]
775 fn test_network_quality_serialization() {
776 let quality = NetworkQuality::High;
777 let json = serde_json::to_string(&quality).unwrap();
778 assert_eq!(json, "\"high\"");
779
780 let quality: NetworkQuality = serde_json::from_str("\"suspicious\"").unwrap();
781 assert!(matches!(quality, NetworkQuality::Suspicious));
782 }
783
784 #[test]
785 fn test_account_info_deserialization() {
786 let json = r#"{
787 "id": "123456",
788 "name": "Test User",
789 "screen_name": "testuser",
790 "followers_count": 1000,
791 "verified": true
792 }"#;
793
794 let info: AccountInfo = serde_json::from_str(json).unwrap();
795 assert_eq!(info.id, Some("123456".to_string()));
796 assert_eq!(info.screen_name, Some("testuser".to_string()));
797 assert_eq!(info.followers_count, Some(1000));
798 assert_eq!(info.verified, Some(true));
799 }
800
801 #[test]
802 fn test_score_response_deserialization() {
803 let json = r#"{
804 "score": 75.5
805 }"#;
806
807 let response: ScoreResponse = serde_json::from_str(json).unwrap();
808 assert!((response.score - 75.5).abs() < 0.001);
809 }
810
811 #[test]
812 fn test_account_deserialization_with_typo() {
813 let json = r#"{
815 "id": "123",
816 "screeName": "testuser",
817 "followersCount": 500,
818 "friendsCount": 200,
819 "score": 65.0
820 }"#;
821
822 let account: Account = serde_json::from_str(json).unwrap();
823 assert_eq!(account.screen_name, Some("testuser".to_string()));
824 assert_eq!(account.followers_count, Some(500));
825 assert_eq!(account.friends_count, Some(200));
826 assert_eq!(account.score, Some(65.0));
827 }
828
829 #[test]
830 fn test_classify_influence() {
831 assert_eq!(classify_influence(2000000), "Mega Influencer");
832 assert_eq!(classify_influence(500000), "Macro Influencer");
833 assert_eq!(classify_influence(50000), "Mid-tier Influencer");
834 assert_eq!(classify_influence(5000), "Micro Influencer");
835 assert_eq!(classify_influence(500), "Regular User");
836 }
837}