1use serde::{Deserialize, Deserializer, de};
2use thiserror::Error;
3
4pub const TOKEN_URL: &str = "https://graph.threads.net/oauth/access_token";
5pub const OAUTH_SCOPES: &str =
6 "threads_basic,threads_read_replies,threads_manage_replies,threads_content_publish";
7
8fn deserialize_user_id_opt<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
10where
11 D: Deserializer<'de>,
12{
13 #[derive(Deserialize)]
14 #[serde(untagged)]
15 enum StringOrNumber {
16 String(String),
17 Number(u64),
18 }
19
20 let opt: Option<StringOrNumber> = Option::deserialize(deserializer)?;
21 match opt {
22 Some(StringOrNumber::String(s)) => s.parse().map(Some).map_err(de::Error::custom),
23 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
24 None => Ok(None),
25 }
26}
27
28#[derive(Debug, Deserialize)]
29pub struct TokenResponse {
30 pub access_token: String,
31 #[allow(dead_code)]
32 #[serde(default, deserialize_with = "deserialize_user_id_opt")]
33 pub user_id: Option<u64>,
34 #[serde(default)]
36 pub expires_in: Option<u64>,
37}
38
39#[derive(Debug, Error)]
40pub enum TokenExchangeError {
41 #[error("Request failed: {0}")]
42 Request(String),
43 #[error("HTTP {status}: {body}")]
44 Http { status: u16, body: String },
45 #[error("Parse error: {0}")]
46 Parse(String),
47}
48
49async fn parse_token_response(
51 response: reqwest::Response,
52) -> Result<TokenResponse, TokenExchangeError> {
53 let body = response
54 .text()
55 .await
56 .map_err(|e| TokenExchangeError::Parse(e.to_string()))?;
57
58 serde_json::from_str(&body).map_err(|e| {
59 tracing::debug!(response_body = %body, "Failed to parse token response");
60 TokenExchangeError::Parse(e.to_string())
61 })
62}
63
64pub async fn exchange_code(
66 client_id: &str,
67 client_secret: &str,
68 redirect_uri: &str,
69 code: &str,
70) -> Result<TokenResponse, TokenExchangeError> {
71 let client = reqwest::Client::new();
72
73 let params = [
74 ("client_id", client_id),
75 ("client_secret", client_secret),
76 ("grant_type", "authorization_code"),
77 ("redirect_uri", redirect_uri),
78 ("code", code),
79 ];
80
81 let response = client
82 .post(TOKEN_URL)
83 .form(¶ms)
84 .send()
85 .await
86 .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
87
88 if !response.status().is_success() {
89 let status = response.status().as_u16();
90 let body = response.text().await.unwrap_or_default();
91 return Err(TokenExchangeError::Http { status, body });
92 }
93
94 parse_token_response(response).await
95}
96
97pub async fn exchange_for_long_lived_token(
99 client_secret: &str,
100 short_lived_token: &str,
101) -> Result<TokenResponse, TokenExchangeError> {
102 let client = reqwest::Client::new();
103
104 let url = format!(
105 "https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret={}&access_token={}",
106 client_secret, short_lived_token
107 );
108
109 let response = client
110 .get(&url)
111 .send()
112 .await
113 .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
114
115 if !response.status().is_success() {
116 let status = response.status().as_u16();
117 let body = response.text().await.unwrap_or_default();
118 return Err(TokenExchangeError::Http { status, body });
119 }
120
121 parse_token_response(response).await
122}
123
124pub async fn refresh_access_token(
126 long_lived_token: &str,
127) -> Result<TokenResponse, TokenExchangeError> {
128 let client = reqwest::Client::new();
129
130 let url = format!(
131 "https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token={}",
132 long_lived_token
133 );
134
135 let response = client
136 .get(&url)
137 .send()
138 .await
139 .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
140
141 if !response.status().is_success() {
142 let status = response.status().as_u16();
143 let body = response.text().await.unwrap_or_default();
144 return Err(TokenExchangeError::Http { status, body });
145 }
146
147 parse_token_response(response).await
148}