tgbot-mastodon-sync 0.2.2

A Telegram bot synchronizes Telegram messages to Mastodon
Documentation
use std::{str::FromStr, sync::Arc};

use anyhow::{anyhow, bail};
use const_format::formatcp;
use mastodon_async::{
    entities::attachment::ProcessedAttachment, prelude::*, registration::Registered, scopes,
};
pub use mastodon_async::{Language, StatusBuilder, Visibility};
use serde_json as json;
use spdlog::prelude::*;
use teloxide::types::UserId;
use tokio::{
    fs::File,
    io::AsyncRead,
    time::{self},
};

use crate::{config, InstanceState};

pub struct Client {
    inst_state: Arc<InstanceState>,
}

impl Client {
    pub fn new(inst_state: Arc<InstanceState>) -> Self {
        Self { inst_state }
    }

    pub async fn login(&self, tg_user_id: UserId) -> anyhow::Result<LoginUser> {
        let login_user = self
            .load_login_user(tg_user_id)
            .await
            .map_err(|err| anyhow!("failed to query user login data: {err}"))?;
        Ok(login_user)
    }

    pub async fn authorization_url(&self, domain: impl AsRef<str>) -> anyhow::Result<String> {
        let domain = domain.as_ref();

        let client = self.register_client(domain).await.map_err(|err| {
            error!("failed to register client for domain '{domain}', err: '{err}'");
            anyhow!("Failed to register client for domain '{domain}\n\n{err}")
        })?;

        Ok(client.authorize_url()?)
    }

    pub async fn authorize(
        &self,
        domain: impl AsRef<str>,
        tg_user_id: UserId,
        auth_code: impl AsRef<str>,
    ) -> anyhow::Result<LoginUser> {
        let domain = domain.as_ref();

        let client = self.register_client(domain).await.map_err(|err| {
            error!("failed to query client for domain '{domain}', err: '{err}'");
            anyhow!("Failed to query client for domain '{domain}\n\n{err}")
        })?;

        let login_user = LoginUser {
            inst: client.complete(auth_code.as_ref()).await?,
            tg_user_id,
        };
        self.save_login_user(tg_user_id, &login_user)
            .await
            .map_err(|err| anyhow!("failed to save user login data: {err}"))?;
        Ok(login_user)
    }

    pub async fn revoke(&self, login_user: &LoginUser) -> anyhow::Result<()> {
        self.delete_login_user(login_user.tg_user_id).await
    }
}

impl Client {
    pub async fn register_client(&self, domain: impl AsRef<str>) -> anyhow::Result<Registered> {
        let domain = domain.as_ref();

        let client = match self.query_client(domain).await {
            Ok(client) => client,
            Err(_) => {
                let client = Registration::new(domain)
                    .client_name(config::PACKAGE.name)
                    .website("https://github.com/SpriteOvO/tgbot-mastodon-sync")
                    .scopes(
                        Scopes::write(scopes::Write::Statuses)
                            .and(Scopes::write(scopes::Write::Media)),
                    )
                    .build()
                    .await?;
                self.save_client(&client).await?;
                client
            }
        };

        Ok(client)
    }
}

impl Client {
    async fn save_login_user(
        &self,
        tg_user_id: UserId,
        login_user: &LoginUser,
    ) -> anyhow::Result<()> {
        let (tg_user_id, login_user_data) = (tg_user_id.0 as i64, login_user.serialize());

        sqlx::query!(
            r#"
INSERT OR REPLACE INTO mastodon_login_user ( tg_user_id, mastodon_async_data )
VALUES ( ?1, ?2 )
        "#,
            tg_user_id,
            login_user_data
        )
        .execute(self.inst_state.db.pool())
        .await?;

        Ok(())
    }

    async fn load_login_user(&self, tg_user_id: UserId) -> anyhow::Result<LoginUser> {
        let tg_user_id_num = tg_user_id.0 as i64;

        let record = sqlx::query!(
            r#"
SELECT mastodon_async_data
FROM mastodon_login_user
WHERE tg_user_id = ?1
        "#,
            tg_user_id_num,
        )
        .fetch_one(self.inst_state.db.pool())
        .await?;

        LoginUser::deserialize(record.mastodon_async_data, tg_user_id)
    }

    async fn delete_login_user(&self, tg_user_id: UserId) -> anyhow::Result<()> {
        let tg_user_id_num = tg_user_id.0 as i64;

        _ = sqlx::query!(
            r#"
DELETE FROM mastodon_login_user
WHERE tg_user_id = ?1
        "#,
            tg_user_id_num,
        )
        .execute(self.inst_state.db.pool())
        .await?;

        Ok(())
    }

    async fn save_client(&self, client: &Registered) -> anyhow::Result<()> {
        let (domain, client_id, client_secret, redirect, scopes, force_login) =
            client.clone().into_parts();
        let scopes = scopes.to_string();

        sqlx::query!(
            r#"
INSERT INTO mastodon_client ( domain, client_id, client_secret, redirect, scopes, force_login )
VALUES ( ?1, ?2, ?3, ?4, ?5, ?6 )
        "#,
            domain,
            client_id,
            client_secret,
            redirect,
            scopes,
            force_login
        )
        .execute(self.inst_state.db.pool())
        .await?;

        Ok(())
    }

    async fn query_client(&self, domain: impl AsRef<str>) -> anyhow::Result<Registered> {
        let domain = domain.as_ref();

        let record = sqlx::query!(
            r#"
SELECT client_id, client_secret, redirect, scopes, force_login
FROM mastodon_client
WHERE domain = ?1
        "#,
            domain,
        )
        .fetch_one(self.inst_state.db.pool())
        .await?;

        Ok(Registered::from_parts(
            domain,
            &record.client_id,
            &record.client_secret,
            &record.redirect,
            FromStr::from_str(&record.scopes)?,
            record.force_login != 0,
        ))
    }
}

pub struct LoginUser {
    inst: Mastodon,
    tg_user_id: UserId,
}

impl LoginUser {
    pub fn domain(&self) -> &str {
        &self.inst.data.base
    }

    pub fn tg_user_id(&self) -> UserId {
        self.tg_user_id
    }

    pub async fn attach_media(
        &self,
        mut data: impl AsyncRead + Unpin,
        description: Option<String>,
    ) -> anyhow::Result<ProcessedAttachment> {
        // TODO: Do not write out files when https://github.com/dscottboggs/mastodon-async/issues/60 is implemented

        let temp_file = tempfile::Builder::new()
            .prefix(formatcp!(".{}.", config::PACKAGE.name))
            .tempfile()?;
        let temp_file = temp_file.path();

        let mut file = File::create(temp_file).await?;

        trace!("downloading to temp file '{}'", temp_file.display());
        tokio::io::copy(&mut data, &mut file).await?;

        trace!("download done, uploading it");
        let attachment = self.inst.media(temp_file, description).await?;
        let attachment = tokio::select! {
            r = self.inst.wait_for_processing(attachment, config::WAITING_FOR_SERVER_PROCESS_MEDIA_INTERVAL.into()) => r,
            _ = time::sleep(config::WAITING_FOR_SERVER_PROCESS_MEDIA_TIMEOUT) => bail!("timeout waiting for server processing media")
        }?;

        trace!("upload done");
        Ok(attachment)
    }

    pub async fn post_status(&self, status: NewStatus) -> anyhow::Result<String> {
        let posted = self.inst.new_status(status.clone()).await?;
        let url = posted.url.unwrap_or_else(|| "*invisible*".to_string());
        Ok(url)
    }
}

impl LoginUser {
    fn serialize(&self) -> String {
        json::to_string(&self.inst.data).unwrap()
    }

    fn deserialize(input: impl AsRef<str>, tg_user_id: UserId) -> anyhow::Result<Self> {
        let data: Data = json::from_str(input.as_ref())?;
        Ok(Self {
            inst: data.into(),
            tg_user_id,
        })
    }
}