use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
pub const ILINK_API_ROOT: &str = "https://ilinkai.weixin.qq.com";
pub const CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c";
fn storage_root() -> PathBuf {
dirs::home_dir()
.expect("no home directory")
.join(".config")
.join("wechat-cli")
}
fn accounts_file_path() -> PathBuf {
storage_root().join("accounts.json")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AccountData {
#[serde(rename = "botToken")]
pub bot_token: String,
#[serde(rename = "savedAt")]
pub saved_at: String,
#[serde(rename = "userId")]
pub user_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_tag: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct AccountsFile {
accounts: Vec<AccountData>,
}
fn load_accounts_file() -> Result<AccountsFile> {
let path = accounts_file_path();
if !path.exists() {
return Ok(AccountsFile::default());
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read accounts file `{}`", path.display()))?;
serde_json::from_str(&data).context("failed to parse accounts file")
}
fn save_accounts_file(accounts: &AccountsFile) -> Result<()> {
let path = accounts_file_path();
std::fs::create_dir_all(path.parent().unwrap()).with_context(|| {
format!(
"failed to create storage directory `{}`",
path.parent().unwrap().display()
)
})?;
let json =
serde_json::to_string_pretty(accounts).context("failed to serialize accounts file")?;
std::fs::write(&path, json)
.with_context(|| format!("failed to write accounts file `{}`", path.display()))?;
Ok(())
}
pub fn get_account_ids() -> Result<Vec<String>> {
let accounts = load_accounts_file()?;
Ok(accounts
.accounts
.into_iter()
.map(|account| account.user_id)
.filter(|id| id.ends_with("@im.wechat"))
.collect())
}
pub fn get_account_data(account_id: &str) -> Result<AccountData> {
let accounts = load_accounts_file()?;
accounts
.accounts
.into_iter()
.find(|account| account.user_id == account_id)
.ok_or_else(|| anyhow!("account `{account_id}` not found"))
}
pub fn save_account_data(account_id: &str, data: &AccountData) -> Result<()> {
let mut accounts = load_accounts_file()?;
if let Some(existing) = accounts
.accounts
.iter_mut()
.find(|account| account.user_id == account_id)
{
*existing = AccountData {
bot_token: data.bot_token.clone(),
saved_at: data.saved_at.clone(),
user_id: data.user_id.clone(),
route_tag: data.route_tag.clone(),
};
} else {
accounts.accounts.push(AccountData {
bot_token: data.bot_token.clone(),
saved_at: data.saved_at.clone(),
user_id: data.user_id.clone(),
route_tag: data.route_tag.clone(),
});
}
save_accounts_file(&accounts)
}
pub fn delete_account_data(account_id: &str) -> Result<()> {
let mut accounts = load_accounts_file()?;
let original_len = accounts.accounts.len();
accounts
.accounts
.retain(|account| account.user_id != account_id);
if accounts.accounts.len() == original_len {
return Err(anyhow!("account `{account_id}` not found"));
}
save_accounts_file(&accounts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_account_data_serde() {
let data = AccountData {
bot_token: "tok_abc".to_string(),
saved_at: "2025-01-01T00:00:00Z".to_string(),
user_id: "user_123".to_string(),
route_tag: None,
};
let json = serde_json::to_string(&data).unwrap();
let deserialized: AccountData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.bot_token, data.bot_token);
assert_eq!(deserialized.saved_at, data.saved_at);
assert_eq!(deserialized.user_id, data.user_id);
}
#[test]
fn test_account_data_with_route_tag() {
let data = AccountData {
bot_token: "tok_abc".to_string(),
saved_at: "2025-01-01T00:00:00Z".to_string(),
user_id: "user_123".to_string(),
route_tag: Some("tag_xyz".to_string()),
};
let json = serde_json::to_string(&data).unwrap();
let deserialized: AccountData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.route_tag, Some("tag_xyz".to_string()));
}
#[test]
fn test_account_data_without_route_tag() {
let json = r#"{"botToken":"tok_abc","savedAt":"2025-01-01T00:00:00Z","userId":"user_123"}"#;
let data: AccountData = serde_json::from_str(json).unwrap();
assert_eq!(data.route_tag, None);
}
}