wechat-cli 0.2.1

A CLI tool to interact with a Wechat iLink bot.
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
use mime_guess::mime;

use crate::{
    commands::account::{AccountSession, build_client, load_account, load_account_by_index},
    wechat::api::WeixinApiClient,
    wechat::media::{OutboundMediaKind, build_media_item, upload_media},
};

pub async fn run(
    account: Option<usize>,
    user_id: Option<&str>,
    bot_token: Option<&str>,
    route_tag: Option<&str>,
    context_token: Option<&str>,
    text: Option<&str>,
    file_path: Option<&Path>,
    caption: Option<&str>,
) -> Result<()> {
    let send_target = resolve_send_target(account, user_id, bot_token, route_tag)?;

    match (text, file_path) {
        (Some(text), None) => send_text(&send_target, context_token, text).await,
        (None, Some(file_path)) => {
            send_media(&send_target, context_token, file_path, caption).await
        }
        (Some(_), Some(_)) => bail!("`--text` and `--file` cannot be used together"),
        (None, None) => bail!("one of `--text` or `--file` is required"),
    }
}

enum SendTarget {
    Saved(AccountSession),
    Explicit {
        user_id: String,
        client: WeixinApiClient,
        display_name: String,
    },
}

async fn send_text(target: &SendTarget, context_token: Option<&str>, text: &str) -> Result<()> {
    match target {
        SendTarget::Saved(session) => {
            let client = build_client(session);
            let context_token = require_context_token(&session.user_id, context_token)?;
            client
                .send_text_message(&session.data.user_id, &context_token, text)
                .await
                .context("failed to send text message")?;
            println!("sent text message to `{}`", session.data.user_id);
        }
        SendTarget::Explicit {
            user_id,
            client,
            display_name,
        } => {
            let context_token = require_context_token(display_name, context_token)?;
            client
                .send_text_message(user_id, &context_token, text)
                .await
                .context("failed to send text message")?;
            println!("sent text message to `{user_id}` using {display_name}");
        }
    }
    Ok(())
}

async fn send_media(
    target: &SendTarget,
    context_token: Option<&str>,
    file_path: &Path,
    caption: Option<&str>,
) -> Result<()> {
    if !file_path.is_file() {
        bail!("file `{}` does not exist", file_path.display());
    }

    let media_kind = detect_media_kind(file_path);
    match target {
        SendTarget::Saved(session) => {
            let client = build_client(session);
            let context_token = require_context_token(&session.user_id, context_token)?;
            let uploaded = upload_media(&client, &session.data.user_id, file_path, media_kind)
                .await
                .with_context(|| format!("failed to upload `{}`", file_path.display()))?;
            let media_item = build_media_item(media_kind, &uploaded);

            client
                .send_media_message(&session.data.user_id, &context_token, caption, media_item)
                .await
                .with_context(|| format!("failed to send `{}`", file_path.display()))?;

            println!(
                "sent {} `{}` to `{}`",
                match media_kind {
                    OutboundMediaKind::Image => "image",
                    OutboundMediaKind::File => "file",
                },
                file_path.display(),
                session.data.user_id,
            );
        }
        SendTarget::Explicit {
            user_id,
            client,
            display_name,
        } => {
            let context_token = require_context_token(display_name, context_token)?;
            let uploaded = upload_media(client, user_id, file_path, media_kind)
                .await
                .with_context(|| format!("failed to upload `{}`", file_path.display()))?;
            let media_item = build_media_item(media_kind, &uploaded);

            client
                .send_media_message(user_id, &context_token, caption, media_item)
                .await
                .with_context(|| format!("failed to send `{}`", file_path.display()))?;

            println!(
                "sent {} `{}` to `{}` using {}",
                match media_kind {
                    OutboundMediaKind::Image => "image",
                    OutboundMediaKind::File => "file",
                },
                file_path.display(),
                user_id,
                display_name,
            );
        }
    }
    Ok(())
}

fn resolve_send_target(
    account: Option<usize>,
    user_id: Option<&str>,
    bot_token: Option<&str>,
    route_tag: Option<&str>,
) -> Result<SendTarget> {
    let using_explicit = bot_token.is_some() || route_tag.is_some();

    if using_explicit {
        if account.is_some() {
            bail!("`--account` cannot be used with `--bot-token` / `--route-tag`");
        }

        let bot_token = bot_token
            .ok_or_else(|| anyhow!("`--bot-token` is required in explicit credential mode"))?;
        let user_id = user_id
            .ok_or_else(|| anyhow!("`--user-id` is required in explicit credential mode"))?;

        return Ok(SendTarget::Explicit {
            user_id: user_id.to_string(),
            client: WeixinApiClient::new(bot_token, route_tag.map(str::to_string)),
            display_name: "explicit bot token".to_string(),
        });
    }

    if account.is_some() && user_id.is_some() {
        bail!("`--account` and `--user-id` cannot be used together in saved account mode");
    }

    if let Some(index) = account {
        return Ok(SendTarget::Saved(load_account_by_index(index)?));
    }

    if let Some(user_id) = user_id {
        return Ok(SendTarget::Saved(load_account(Some(user_id))?));
    }

    Ok(SendTarget::Saved(load_account_by_index(0)?))
}

fn detect_media_kind(file_path: &Path) -> OutboundMediaKind {
    match mime_guess::from_path(file_path).first() {
        Some(mime_type) if mime_type.type_() == mime::IMAGE => OutboundMediaKind::Image,
        _ => OutboundMediaKind::File,
    }
}

fn require_context_token(account_label: &str, explicit: Option<&str>) -> Result<String> {
    if let Some(context_token) = explicit {
        return Ok(context_token.to_string());
    }

    bail!(
        "missing `--context-token` for `{account_label}`; run `wechat-cli get-context-token` for the bound user and pass the printed token"
    )
}