diff --git a/crates/titen-core/src/lib.rs b/crates/titen-core/src/lib.rs index b9c1d35..f0e9fbf 100644 --- a/crates/titen-core/src/lib.rs +++ b/crates/titen-core/src/lib.rs @@ -30,6 +30,7 @@ pub mod store; pub mod threads_client; pub mod time; pub mod trend; +pub mod user_id_resolution; pub use crypto::Cipher; pub use error::{Result, TitenError}; diff --git a/crates/titen-core/src/store.rs b/crates/titen-core/src/store.rs index 6e4dba3..b0bb5cd 100644 --- a/crates/titen-core/src/store.rs +++ b/crates/titen-core/src/store.rs @@ -34,6 +34,12 @@ pub struct Store { } impl Store { + /// Read-only access to the underlying pool (used by cross-module + /// persistence helpers, e.g. user_id self-healing in the threads client). + pub fn pool(&self) -> &sqlx::SqlitePool { + &self.pool + } + /// Create a store with encryption enabled. /// /// The cipher is loaded from `TITEN_ENCRYPTION_KEY` env var. diff --git a/crates/titen-core/src/threads_client.rs b/crates/titen-core/src/threads_client.rs index 2e750fd..a5e83a9 100644 --- a/crates/titen-core/src/threads_client.rs +++ b/crates/titen-core/src/threads_client.rs @@ -3,6 +3,7 @@ use crate::models::{ CommentData, ContainerStatus, InsightMetric, PublishingLimit, UserInsightMetric, UserProfile, }; use crate::store::Store; +use crate::user_id_resolution::is_user_id_usable; use reqwest::Client; use tracing::{info, warn}; @@ -35,6 +36,45 @@ impl ThreadsClient { Self { http, store } } + /// Return the account with a guaranteed-usable `user_id` (#265). + /// + /// Cheap when healthy (in-memory check only). When the id is missing or + /// malformed, resolves via `GET /me`, persists the correction to the DB, + /// and returns the healed account. Callers that build + /// `/v1.0/{user_id}/...` URLs should route through this instead of using + /// `account.user_id` directly. Design & rationale: `user_id_resolution`. + pub async fn ensure_resolved_user_id( + &self, + account: &crate::models::Account, + ) -> Result { + if is_user_id_usable(&account.user_id) { + return Ok(account.clone()); + } + + // Do NOT log the stored value — in the observed failure mode it IS + // the raw access token (mirrored into the user_id slot). + warn!( + "Account @{} has unusable user_id ({} chars, non-numeric) — self-healing via /me", + account.username, + account.user_id.len() + ); + let (user_id, _username) = self.resolve_account(&account.access_token).await?; + + sqlx::query("UPDATE accounts SET user_id = ?1, updated_at = datetime('now') WHERE id = ?2") + .bind(&user_id) + .bind(&account.id) + .execute(self.store.pool()) + .await?; + + let mut healed = account.clone(); + healed.user_id = user_id; + info!( + "Account @{} self-healed: user_id set to {}", + healed.username, healed.user_id + ); + Ok(healed) + } + // ─── Internal Helpers ───────────────────────────────────── /// Send a request and parse JSON, checking for Threads API errors. @@ -384,6 +424,8 @@ impl ThreadsClient { body["video_url"] = serde_json::json!(url); } + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!("{THREADS_GRAPH_API}/v1.0/{}/threads", account.user_id); let resp = self.threads_post(&url, &body).await?; @@ -446,6 +488,8 @@ impl ThreadsClient { body["location_id"] = serde_json::json!(loc_id); } + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!("{THREADS_GRAPH_API}/v1.0/{}/threads", account.user_id); let resp = self.threads_post(&url, &body).await?; @@ -470,6 +514,8 @@ impl ThreadsClient { account: &crate::models::Account, creation_id: &str, ) -> Result { + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads_publish", account.user_id @@ -845,6 +891,8 @@ impl ThreadsClient { metrics: Option<&str>, ) -> Result> { let fields = metrics.unwrap_or("likes,replies,reposts,quotes,views,shares"); + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{threads_post_id}/insights?metric={fields}&access_token={}", account.access_token @@ -1078,6 +1126,8 @@ impl ThreadsClient { &self, account: &crate::models::Account, ) -> Result { + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads_publishing_limit?access_token={}", account.user_id, account.access_token @@ -1268,6 +1318,8 @@ impl ThreadsClient { limit: Option, ) -> Result> { let limit_val = limit.unwrap_or(25); + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads?where=MENTION&fields=id,text,username,timestamp,media_type,permalink&limit={limit_val}&access_token={}", account.user_id, account.access_token diff --git a/crates/titen-core/src/user_id_resolution.rs b/crates/titen-core/src/user_id_resolution.rs new file mode 100644 index 0000000..af41f24 --- /dev/null +++ b/crates/titen-core/src/user_id_resolution.rs @@ -0,0 +1,52 @@ +//! Self-healing user_id resolution (#265). +//! +//! Some accounts carry a NULL/malformed `user_id` in the DB (an OAuth +//! exchange path historically failed to persist it — the ajianaz account +//! shipped that way for weeks). Every Graph URL is built as +//! `/v1.0/{user_id}/...`, so a NULL id makes Titen use the raw access token +//! as the path segment and Meta rejects with "Unsupported get request". The +//! token itself is fine — which `GET /me` proves — so the account can heal +//! itself: resolve the real id once, persist it, and never pay the cost +//! again. +//! +//! The actual `ensure_resolved_user_id` implementation lives in +//! `threads_client` (same module as the private `store` field it persists +//! through). This module holds the usability predicate and its tests. + +/// A user_id is usable when it is a non-empty numeric string. Meta ids are +/// numeric; the observed failure mode stores the raw TOKEN in the id slot, +/// which is base64-ish and always contains non-digits. +pub fn is_user_id_usable(user_id: &str) -> bool { + !user_id.is_empty() && user_id.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::is_user_id_usable; + + #[test] + fn numeric_ids_are_usable() { + assert!(is_user_id_usable("34327938090154478")); + assert!(is_user_id_usable("1")); + } + + #[test] + fn empty_is_not_usable() { + assert!(!is_user_id_usable("")); + } + + #[test] + fn raw_token_as_id_is_not_usable() { + // The observed production failure mode: the token stored in the + // user_id slot (ajianaz account, 2026-09-10). Synthetic value with + // the same shape — a real token must never be embedded in tests. + assert!(!is_user_id_usable( + "THXXSYNT0HET1CEXAMPL3TOKEN0PLACEH0LD3RVALUE0ONLY0FOR0TESTS0XX" + )); + } + + #[test] + fn alphanumeric_ids_are_not_usable() { + assert!(!is_user_id_usable("17ab34")); + } +}