wx-bot-sdk 0.1.3

Standalone Weixin Bot SDK in Rust
Documentation
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf};

use crate::{messaging::send::clear_context_tokens_for_account, storage::resolve_state_dir};

pub const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub const CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c";

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct WeixinAccountData {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub saved_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_id: Option<String>,
}

#[derive(Clone, Debug)]
pub struct ResolvedWeixinAccount {
    pub account_id: String,
    pub base_url: String,
    pub cdn_base_url: String,
    pub token: Option<String>,
    pub user_id: Option<String>,
    pub configured: bool,
}

fn accounts_dir() -> PathBuf {
    resolve_state_dir().join("accounts")
}
fn account_path(account_id: &str) -> PathBuf {
    accounts_dir().join(format!("{account_id}.json"))
}
fn account_index_path() -> PathBuf {
    resolve_state_dir().join("accounts.json")
}

pub(crate) fn normalize_account_id(account_id: &str) -> String {
    account_id
        .trim()
        .replace('@', "-")
        .replace('.', "-")
        .to_ascii_lowercase()
}

pub(crate) fn derive_raw_account_id(normalized_id: &str) -> Option<String> {
    normalized_id
        .strip_suffix("-im-bot")
        .map(|prefix| format!("{prefix}@im.bot"))
        .or_else(|| {
            normalized_id
                .strip_suffix("-im-wechat")
                .map(|prefix| format!("{prefix}@im.wechat"))
        })
}

fn account_path_candidates(account_id: &str) -> Vec<PathBuf> {
    let mut ids = vec![account_id.trim().to_string()];
    let normalized = normalize_account_id(account_id);
    if !ids.iter().any(|id| id == &normalized) {
        ids.push(normalized);
    }
    if let Some(raw) = derive_raw_account_id(account_id)
        && !ids.iter().any(|id| id == &raw)
    {
        ids.push(raw);
    }
    ids.into_iter().map(|id| account_path(&id)).collect()
}

pub fn list_weixin_account_ids() -> Vec<String> {
    fs::read_to_string(account_index_path())
        .ok()
        .and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
        .unwrap_or_default()
        .into_iter()
        .filter(|id| !id.trim().is_empty())
        .collect()
}

pub fn register_weixin_account_id(account_id: &str) -> crate::Result<()> {
    fs::create_dir_all(resolve_state_dir())?;
    let mut ids = list_weixin_account_ids();
    if !ids.iter().any(|id| id == account_id) {
        ids.push(account_id.to_string());
        fs::write(account_index_path(), serde_json::to_string_pretty(&ids)?)?;
    }
    Ok(())
}

pub fn unregister_weixin_account_id(account_id: &str) -> crate::Result<()> {
    let existing = list_weixin_account_ids();
    let updated: Vec<_> = existing.into_iter().filter(|id| id != account_id).collect();
    fs::write(
        account_index_path(),
        serde_json::to_string_pretty(&updated)?,
    )?;
    Ok(())
}

pub fn load_weixin_account(account_id: &str) -> Option<WeixinAccountData> {
    for path in account_path_candidates(account_id) {
        if let Some(data) = fs::read_to_string(path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
        {
            return Some(data);
        }
    }
    None
}

pub fn save_weixin_account(
    account_id: &str,
    token: Option<&str>,
    base_url: Option<&str>,
    user_id: Option<&str>,
) -> crate::Result<()> {
    fs::create_dir_all(accounts_dir())?;
    let account_id = normalize_account_id(account_id);
    let existing = load_weixin_account(&account_id).unwrap_or_default();
    let token = token
        .and_then(non_empty)
        .map(ToOwned::to_owned)
        .or(existing.token);
    let base_url = base_url
        .and_then(non_empty)
        .map(ToOwned::to_owned)
        .or(existing.base_url);
    let user_id = user_id
        .and_then(non_empty)
        .map(ToOwned::to_owned)
        .or(existing.user_id);
    let data = WeixinAccountData {
        saved_at: token.as_ref().map(|_| Utc::now().to_rfc3339()),
        token,
        base_url,
        user_id,
    };
    fs::write(
        account_path(&account_id),
        serde_json::to_string_pretty(&data)?,
    )?;
    register_weixin_account_id(&account_id)?;
    Ok(())
}

fn non_empty(s: &str) -> Option<&str> {
    let t = s.trim();
    (!t.is_empty()).then_some(t)
}

pub fn clear_weixin_account(account_id: &str) {
    for file in [
        format!("{account_id}.json"),
        format!("{account_id}.sync.json"),
        format!("{account_id}.context-tokens.json"),
    ] {
        let _ = fs::remove_file(accounts_dir().join(file));
    }
}

pub fn clear_stale_accounts_for_user_id(
    current_account_id: &str,
    user_id: &str,
) -> crate::Result<()> {
    if user_id.is_empty() {
        return Ok(());
    }
    for id in list_weixin_account_ids() {
        if id == current_account_id {
            continue;
        }
        if load_weixin_account(&id)
            .and_then(|d| d.user_id)
            .map(|u| u.trim() == user_id)
            .unwrap_or(false)
        {
            clear_context_tokens_for_account(&id);
            clear_weixin_account(&id);
            unregister_weixin_account_id(&id)?;
        }
    }
    Ok(())
}

pub fn resolve_weixin_account(account_id: &str) -> crate::Result<ResolvedWeixinAccount> {
    if account_id.trim().is_empty() {
        return Err("accountId is required".into());
    }
    let account_id = normalize_account_id(account_id);
    let data = load_weixin_account(&account_id);
    let token = data
        .as_ref()
        .and_then(|d| d.token.as_ref())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    Ok(ResolvedWeixinAccount {
        account_id: account_id.to_string(),
        base_url: data
            .as_ref()
            .and_then(|d| d.base_url.as_ref())
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
        cdn_base_url: CDN_BASE_URL.to_string(),
        user_id: data
            .as_ref()
            .and_then(|d| d.user_id.as_ref())
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty()),
        configured: token.is_some(),
        token,
    })
}