tgbot-mastodon-sync 0.2.2

A Telegram bot synchronizes Telegram messages to Mastodon
Documentation
use std::slice;

use anyhow::anyhow;
use serde_json as json;
use spdlog::prelude::*;
use teloxide::{
    prelude::*,
    types::{
        FileMeta,
        MediaKind::{self as InnerMediaKind, *},
        MessageEntity, MessageId, MessageKind, PhotoSize,
    },
};

use crate::InstanceState;

pub struct MediaKind(InnerMediaKind);

impl MediaKind {
    pub fn inner(&self) -> &InnerMediaKind {
        &self.0
    }

    pub fn file(&self) -> Option<&FileMeta> {
        let file = match &self.0 {
            Animation(m) => &m.animation.file,
            Audio(m) => &m.audio.file,
            Document(m) => &m.document.file,
            Photo(m) => &Self::choice_best_photo(&m.photo).file,
            Sticker(m) => &m.sticker.file,
            Video(m) => &m.video.file,
            VideoNote(m) => &m.video_note.file,
            Voice(m) => &m.voice.file,
            Contact(_) | Game(_) | Venue(_) | Location(_) | Poll(_) | Text(_) | Migration(_) => {
                return None
            }
        };
        Some(file)
    }

    pub fn caption(&self) -> Option<&str> {
        match &self.0 {
            Animation(m) => m.caption.as_deref(),
            Audio(m) => m.caption.as_deref(),
            Document(m) => m.caption.as_deref(),
            Game(m) => m.game.text.as_deref(),
            Photo(m) => m.caption.as_deref(),
            Text(m) => Some(&*m.text),
            Video(m) => m.caption.as_deref(),
            Voice(m) => m.caption.as_deref(),
            Contact(_) | Venue(_) | Location(_) | Poll(_) | Sticker(_) | VideoNote(_)
            | Migration(_) => None,
        }
    }

    pub fn entities(&self) -> Option<&[MessageEntity]> {
        let caption_entities = match &self.0 {
            Animation(m) => &m.caption_entities,
            Audio(m) => &m.caption_entities,
            Document(m) => &m.caption_entities,
            Game(m) => return m.game.text_entities.as_deref(),
            Photo(m) => &m.caption_entities,
            Text(m) => &m.entities,
            Video(m) => &m.caption_entities,
            Voice(m) => &m.caption_entities,
            Contact(_) | Venue(_) | Location(_) | Poll(_) | Sticker(_) | VideoNote(_)
            | Migration(_) => return None,
        };
        Some(caption_entities)
    }

    pub fn has_media_spoiler(&self) -> bool {
        match &self.0 {
            Animation(m) => m.has_media_spoiler,
            Photo(m) => m.has_media_spoiler,
            Video(m) => m.has_media_spoiler,
            Audio(_) | Contact(_) | Document(_) | Game(_) | Venue(_) | Location(_) | Poll(_)
            | Sticker(_) | Text(_) | VideoNote(_) | Voice(_) | Migration(_) => false,
        }
    }

    pub fn choice_best_photo(photos: &[PhotoSize]) -> &PhotoSize {
        photos
            .iter()
            .max_by(|a, b| (a.width * b.height).cmp(&(b.width * b.height)))
            .unwrap()
    }
}

impl MediaKind {
    fn serialize(inner: &InnerMediaKind) -> anyhow::Result<String> {
        Ok(json::to_string(inner)?)
    }

    fn deserialize(data: impl AsRef<str>) -> anyhow::Result<Self> {
        Ok(Self(json::from_str(data.as_ref())?))
    }
}

pub enum Media {
    Single(Box<MediaKind>),
    Group {
        medias: Vec<MediaKind>,
        group_id: String,
    },
}

impl Media {
    pub fn iter(&self) -> impl Iterator<Item = &MediaKind> {
        match self {
            Self::Single(media) => slice::from_ref(&**media).iter(),
            Self::Group { medias, .. } => medias.iter(),
        }
    }

    pub fn len(&self) -> usize {
        match self {
            Self::Single(_) => 1,
            Self::Group { medias, .. } => medias.len(),
        }
    }

    pub fn caption(&self) -> Option<&str> {
        match self {
            Self::Single(media) => media.caption(),
            Self::Group { medias, .. } => medias.iter().find_map(|m| m.caption()),
        }
    }

    pub fn entities(&self) -> Option<&[MessageEntity]> {
        match self {
            Self::Single(media) => media.entities(),
            Self::Group { medias, .. } => medias
                .iter()
                .find(|m| m.caption().is_some())
                .and_then(|m| m.entities()),
        }
    }

    pub async fn query(state: &InstanceState, msg: &Message) -> anyhow::Result<Option<Self>> {
        let msgc = match &msg.kind {
            MessageKind::Common(common) => common,
            _ => return Ok(None),
        };

        let result = match msg.media_group_id() {
            None => {
                if let Text(_) = msgc.media_kind {
                    return Ok(None);
                }
                Self::Single(Box::new(MediaKind(msgc.media_kind.clone())))
            }
            Some(media_group_id) => {
                let medias = query_media_group(state, media_group_id)
                    .await
                    .map_err(|err| anyhow!("failed to query media group: {err}"))?;

                Self::Group {
                    medias,
                    group_id: media_group_id.into(),
                }
            }
        };

        Ok(Some(result))
    }
}

pub async fn on_new_or_edited_message(inst_state: &InstanceState, msg: &Message) {
    let msgc = match &msg.kind {
        MessageKind::Common(common) => common,
        _ => return,
    };

    if let Some(media_group_id) = msg.media_group_id() {
        _ = insert_media_to_group(inst_state, &msgc.media_kind, msg.id, media_group_id)
            .await
            .map_err(|err| {
                error!(
                    "failed to cache media. chat id '{}', msg id '{}', err: '{}'",
                    msg.chat.id, msg.id, err
                );
            });

        trace!(
            "media cached successfully. chat id '{}', msg id '{}'",
            msg.chat.id,
            msg.id
        );
    }
}

async fn insert_media_to_group(
    inst_state: &InstanceState,
    media: &InnerMediaKind,
    msg_id: MessageId,
    media_group_id: impl AsRef<str>,
) -> anyhow::Result<()> {
    let media_json = MediaKind::serialize(media)?;
    let msg_id = msg_id.0;
    let media_group_id = media_group_id.as_ref();

    sqlx::query!(
        r#"
INSERT OR REPLACE INTO telegram_media_group ( group_id, msg_id, media_json )
VALUES ( ?1, ?2, ?3 )
        "#,
        media_group_id,
        msg_id,
        media_json
    )
    .execute(inst_state.db.pool())
    .await?;

    Ok(())
}

async fn query_media_group(
    inst_state: &InstanceState,
    media_group_id: impl AsRef<str>,
) -> anyhow::Result<Vec<MediaKind>> {
    let media_group_id = media_group_id.as_ref();

    let records = sqlx::query!(
        r#"
SELECT media_json
FROM telegram_media_group
WHERE group_id = ?1
ORDER BY msg_id
        "#,
        media_group_id,
    )
    .fetch_all(inst_state.db.pool())
    .await?;

    records
        .into_iter()
        .map(|r| MediaKind::deserialize(r.media_json))
        .collect()
}