Prototype

This commit is contained in:
Shiroyasha 2024-12-11 12:31:54 +03:00
commit 2844bb9149
Signed by: shiroyashik
GPG key ID: E4953D3940D7860A
19 changed files with 5483 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
/act
.env
note.txt

4359
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

30
Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "doggy-watch"
version = "0.1.0"
edition = "2021"
publish = false
[workspace]
members = [ "entity", "migration", "youtube"]
[dependencies]
entity = { path = "entity" }
youtube = { path = "youtube" }
anyhow = "1.0"
dotenvy = "0.15"
sea-orm = { version = "1.1", features = ["macros", "sqlx-sqlite", "runtime-tokio-rustls", "sqlx-postgres", "with-uuid", "with-chrono"] }
teloxide = { version = "0.13", features = ["macros"] }
tokio = { version = "1.42", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.11", features = ["fast-rng", "v4"] }
chrono = "0.4"
tracing-panic = "0.1"
lazy_static = "1.5.0"
indexmap = "2.7.0"
dashmap = "6.1.0"
# https://github.com/teloxide/teloxide/issues/1154
# [profile.dev]
# opt-level = 1

4
README.md Normal file
View file

@ -0,0 +1,4 @@
Смотрите README v0.2.0 релиза, то что вы здесь видете не для публикации.
---
**АХТУНГ!** В исходниках матюки! **:3**

7
entity/Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
[dependencies]
sea-orm = { version = "1.1", features = ["macros", "sqlx-sqlite", "runtime-tokio-rustls", "sqlx-postgres", "with-uuid", "with-chrono"] }

33
entity/src/actions.rs Normal file
View file

@ -0,0 +1,33 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "actions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uid: String,
pub vid: i32,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::videos::Entity",
from = "Column::Vid",
to = "super::videos::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Videos,
}
impl Related<super::videos::Entity> for Entity {
fn to() -> RelationDef {
Relation::Videos.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

8
entity/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
pub mod prelude;
pub mod actions;
pub mod moderators;
pub mod sea_orm_active_enums;
pub mod videos;

16
entity/src/moderators.rs Normal file
View file

@ -0,0 +1,16 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "moderators")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub uid: String,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

5
entity/src/prelude.rs Normal file
View file

@ -0,0 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
pub use super::actions::Entity as Actions;
pub use super::moderators::Entity as Moderators;
pub use super::videos::Entity as Videos;

View file

@ -0,0 +1,14 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
use sea_orm::entity::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")]
pub enum Status {
#[sea_orm(string_value = "Banned")]
Banned,
#[sea_orm(string_value = "Pending")]
Pending,
#[sea_orm(string_value = "Viewed")]
Viewed,
}

31
entity/src/videos.rs Normal file
View file

@ -0,0 +1,31 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.2
use super::sea_orm_active_enums::Status;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "videos")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
#[sea_orm(unique)]
pub yt_id: String,
pub created_at: DateTime,
pub status: Status,
pub updated_at: Option<DateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::actions::Entity")]
Actions,
}
impl Related<super::actions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Actions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

26
migration/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "1.1.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
# "sqlx-postgres", # `DATABASE_DRIVER` feature
"runtime-tokio-rustls",
"sqlx-postgres",
"with-uuid",
"with-chrono"
]

41
migration/README.md Normal file
View file

@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

12
migration/src/lib.rs Normal file
View file

@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}

View file

@ -0,0 +1,123 @@
use sea_orm::{EnumIter, Iterable};
use sea_orm_migration::{prelude::*, schema::*};
use sea_query::extension::postgres::Type;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_type(
Type::create()
.as_enum(Alias::new("status"))
.values(Status::iter())
.to_owned()
)
.await?;
manager
.create_table(
Table::create()
.table(Videos::Table)
.if_not_exists()
.col(pk_auto(Videos::Id))
.col(string(Videos::Title))
.col(string_uniq(Videos::YtId))
.col(timestamp(Videos::CreatedAt))
.col(enumeration(Videos::Status, Alias::new("status"), Status::iter()))
.col(timestamp_null(Videos::UpdatedAt))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Actions::Table)
.if_not_exists()
.col(pk_auto(Actions::Id))
.col(string_len(Actions::Uid, 36))
.col(integer(Actions::Vid))
.col(timestamp(Actions::CreatedAt))
.foreign_key(
ForeignKey::create()
.name("fk_videos_id")
.from(Actions::Table, Actions::Vid)
.to(Videos::Table, Videos::Id)
.on_delete(ForeignKeyAction::Cascade)
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Moderators::Table)
.if_not_exists()
.col(string_len_uniq(Moderators::Uid, 36).primary_key())
.col(timestamp(Moderators::CreatedAt))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Actions::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Videos::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Moderators::Table).to_owned())
.await?;
manager
.drop_type(
Type::drop().if_exists().name(Alias::new("status")).cascade().to_owned())
.await?;
// manager
// .drop_foreign_key(
// ForeignKey::drop().name("fk_videos_id").to_owned())
// .await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Videos {
Table,
Id,
Title,
YtId,
CreatedAt,
Status,
UpdatedAt
}
#[derive(DeriveIden)]
enum Actions {
Table,
Id,
Vid,
Uid,
CreatedAt
}
#[derive(Iden, EnumIter)]
pub enum Status {
#[iden = "Pending"]
Pending,
#[iden = "Viewed"]
Viewed,
#[iden = "Banned"]
Banned
}
#[derive(DeriveIden)]
enum Moderators {
Table,
Uid,
CreatedAt,
}

6
migration/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

663
src/main.rs Normal file
View file

@ -0,0 +1,663 @@
use std::{env::var, sync::Arc, time::Duration};
use chrono::Local;
use dashmap::DashMap;
use indexmap::IndexMap;
use sea_orm::{prelude::*, ActiveValue::*, Database, Order, QueryOrder};
use teloxide::{
dispatching::dialogue::{GetChatId, InMemStorage}, prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, InputFile, LinkPreviewOptions, ParseMode, User}, utils::{command::BotCommands, html::user_mention}
};
use tokio::time::Instant;
use tracing_panic::panic_hook;
use lazy_static::lazy_static;
const COOLDOWN_DURATION: Duration = Duration::from_secs(30);
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
lazy_static! {
pub static ref LOGGER_ENV: String = {
var("RUST_LOG").unwrap_or(String::from("info"))
};
pub static ref TOKEN: String = {
var("TOKEN").expect("TOKEN env not set.")
};
pub static ref DATABASE_URL: String = {
var("DATABASE_URL").expect("DATABASE_URL env not set.")
};
pub static ref ADMINISTRATORS: Vec<u64> = {
var("ADMINISTRATORS").unwrap_or(String::from(""))
.split(',').filter_map(|s| s.parse().ok()).collect()
};
pub static ref CHANNEL: i64 = {
var("CHANNEL").expect("TOKEN env not set.").parse().expect("Cant't parse env CHANNEL to i64.")
};
}
struct AppState {
db: DatabaseConnection,
administrators: Vec<u64>,
cooldown: DashMap<u64, Instant>
}
impl AppState {
async fn check_rights(&self, uid: &UserId) -> anyhow::Result<Rights> {
use entity::moderators::Entity as Moderators;
Ok(if self.administrators.contains(&uid.0) {
Rights::Administrator
} else if Moderators::find_by_id(uid.to_string()).one(&self.db).await?.is_some() {
Rights::Moderator
} else {
Rights::None
})
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(&*LOGGER_ENV)
// .pretty()
.init();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
panic_hook(panic_info);
prev_hook(panic_info);
}));
tracing::info!("Doggy-Watch v{VERSION}");
tracing::info!("{:?}", *ADMINISTRATORS);
let bot = Bot::new(&*TOKEN);
let db: DatabaseConnection = Database::connect(&*DATABASE_URL).await?;
// teloxide::repl(bot, answer).await;
let state = Arc::new(AppState {db, administrators: (&ADMINISTRATORS).to_vec(), cooldown: DashMap::new()});
// let handler = dptree::entry()
// .branch(Update::filter_message().endpoint(answer))
// .branch(Update::filter_callback_query().endpoint(callback_handler));
let handler = dptree::entry()
.branch(Update::filter_message()
.enter_dialogue::<Message, InMemStorage<DialogueState>, DialogueState>()
.branch(dptree::case![DialogueState::Nothing]
.branch(
dptree::filter_async(is_moderator)
.branch(dptree::filter(|msg: Message| {
if let Some(text) = msg.text() {
recognise_vid(text).is_some()
// проверяем что из сообщения можно достать vid (example: /123 where 123 is vid)
} else {
false
}
}).endpoint(info))
.filter_command::<Command>()
.endpoint(answer)
)
.branch(
dptree::entry()
.filter_command::<Command>()
.endpoint(insufficient_rights)
)
.branch(
dptree::filter(|msg: Message| {
msg.text().is_some() && msg.from.is_some()
})
.endpoint(normal_answer)
))
.branch(dptree::case![DialogueState::NewModeratorInput].endpoint(add_moderator_from_recived_message))
// .branch(dptree::case![DialogueState::RemoveModeratorConfirm { uid }].endpoint(remove_moderator))
)
.branch(Update::filter_callback_query()
.enter_dialogue::<CallbackQuery, InMemStorage<DialogueState>, DialogueState>()
.branch(dptree::case![DialogueState::Nothing].endpoint(change_status))
.branch(dptree::case![DialogueState::RemoveModeratorConfirm { uid }].endpoint(remove_moderator))
.branch(dptree::case![DialogueState::AcceptVideo { accept }].endpoint(accept_video))
.branch(dptree::case![DialogueState::NewModeratorInput].endpoint(cancel))
// .endpoint(callback_handler)
);
Dispatcher::builder(bot, handler)
// Pass the shared state to the handler as a dependency.
.dependencies(dptree::deps![state, InMemStorage::<DialogueState>::new()])
.default_handler(|upd| async move {
tracing::warn!("Unhandled update: {:?}", upd);
})
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
Ok(())
}
async fn is_moderator(state: Arc<AppState>, msg: Message) -> bool {
if let Some(user) = msg.from {
let rights = state.check_rights(&user.id).await;
if let Ok(rights) = rights {
rights.into()
} else {
false
}
} else {
false
}
}
type MyDialogue = Dialogue<DialogueState, InMemStorage<DialogueState>>;
#[derive(Clone, Default)]
pub enum DialogueState {
#[default]
Nothing,
// User
AcceptVideo{ accept: ForAccept },
// Moderator
NewModeratorInput,
RemoveModeratorConfirm{uid: String},
}
#[derive(Clone)]
pub struct ForAccept {
pub ytid: String,
pub uid: u64,
pub title: String,
}
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Список поддерживаемых команд:")]
enum Command {
#[command(description = "отобразить этот текст.")]
Help,
#[command(description = "запустить бота.")]
Start,
#[command(description = "вывести список.")]
List,
// #[command(description = "информация о видео.")]
// I(i32),
// #[command(description = "вывести чёрный список.")]
// Blacklisted,
#[command(description = "добавить в чёрный список.")]
Ban(i32),
// #[command(description = "удалить из чёрного списка.")]
// RemBlacklisted(u128),
#[command(description = "вывести список модераторов.")]
Mods,
#[command(description = "добавить модератора.")]
AddMod,
#[command(description = "удалить модератора.")]
RemMod(String),
About
}
async fn answer(bot: Bot, msg: Message, cmd: Command, state: Arc<AppState>, dialogue: MyDialogue) -> anyhow::Result<()> {
let user = msg.from.unwrap(); // Потому что уже уверены что пользователь администратор или модератор
let rights = state.check_rights(&user.id).await.unwrap();
tracing::info!("{rights:?}");
match cmd {
Command::Help => {
let mut result = String::from(&Command::descriptions().to_string());
result.push_str("\n\nЧтобы получить информацию о видео или изменить его статус просто отправь его номер в чат.");
bot.send_message(msg.chat.id, result).await?;
},
Command::Start => {
let mut result = String::from(&Command::descriptions().to_string());
result.push_str("\n\nЧтобы получить информацию о видео или изменить его статус просто отправь его номер в чат.");
bot.send_message(msg.chat.id, result).await?;
},
Command::List => {
use entity::{actions, videos, sea_orm_active_enums::Status};
struct Video {
id: i32,
title: String,
url: String,
contributors: u64,
}
let videos = videos::Entity::find().filter(videos::Column::Status.eq(Status::Pending)).all(&state.db).await?;
if videos.len() != 0 {
let mut by_date: IndexMap<Date, Vec<Video>> = IndexMap::new();
for video in videos {
let contributors = actions::Entity::find().filter(actions::Column::Vid.eq(video.id)).count(&state.db).await?;
let date = video.created_at.date();
let url = format!("{}{}", youtube::DEFAULT_YT, video.yt_id);
let title = video.title.replace("/", "/ ");
if let Some(entry) = by_date.get_mut(&date) {
entry.push(Video { id: video.id, title, url, contributors });
} else {
by_date.insert(date, vec![Video { id: video.id, title, url, contributors }]);
};
}
by_date.sort_unstable_by(|a, _, c, _| c.cmp(a));
let mut result = String::new();
for (date, mut videos) in by_date {
if result.is_empty() {
result.push_str(&format!("[{}]", date.format("%m.%d")));
} else {
result.push_str(&format!("\n[{}]", date.format("%m.%d")));
}
videos.sort_unstable_by(|a, b| a.contributors.cmp(&b.contributors));
for video in videos {
result.push_str(&format!("\n/{} <a href=\"{}\">📺YT</a> (👀{}) <b>{}</b>\n", video.id, video.url, video.contributors, video.title));
// result.push_str(&format!("\n<a href=\"tg://resolve?domain={}&start=info%20{}\">{}.</a> <b>{}</b> <a href=\"{DEFAULT_YT}{}\">YT</a> ({})", me.username.clone().unwrap(), video.id, video.id, video.title, video.url, video.contributors));
}
}
bot.send_message(msg.chat.id, result).parse_mode(ParseMode::Html).link_preview_options(LinkPreviewOptions { is_disabled: true, url: None, prefer_small_media: false, prefer_large_media: false, show_above_text: false }).await?;
} else {
bot.send_message(msg.chat.id, "Нет видео для просмотра :(").await?;
}
// for (date, value) in by_date.sort_unstable_by(|a, b| b.cmp(a)) {
// result.push_str(&format!("{}: {}\n", date, value));
// }
// let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
// vec![InlineKeyboardButton::callback("Hello1", "1")],
// vec![InlineKeyboardButton::callback("Hello2", "2"), InlineKeyboardButton::callback("Hello1", "1")],
// vec![InlineKeyboardButton::callback("Hello3", "3")],
// ];
// bot.send_message(msg.chat.id, format!("{messages_total:?}")).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
},
// Command::Blacklisted => todo!(),
Command::Ban(vid) => {
use entity::{videos::{Entity, ActiveModel}, sea_orm_active_enums::Status};
let video = Entity::find_by_id(vid).one(&state.db).await?;
if let Some(model) = video {
let title = model.title.clone();
let mut video: ActiveModel = model.into();
video.status = Set(Status::Banned);
video.updated_at = Set(Some(Local::now().naive_local()));
if video.update(&state.db).await.is_ok() {
bot.send_message(msg.chat.id, format!("Видео <b>\"{title}\"</b> успешно добавленно в чёрный список!")).parse_mode(ParseMode::Html).await?;
} else {
bot.send_message(msg.chat.id, "Произошла ошибка обновления записи в базе данных!").await?;
}
} else {
bot.send_message(msg.chat.id, "Не найдено.").await?;
}
},
// Command::RemBlacklisted() => todo!(),
Command::Mods => {
use entity::moderators::{Entity, Model};
let columns: Vec<Model> = Entity::find().all(&state.db).await.unwrap();
if columns.len() != 0 {
let mut str = String::from("Модераторы:");
for col in columns {
tracing::info!("{col:?}");
let uid: u64 = col.uid.parse()?;
let name = bot.get_chat_member(ChatId(uid as i64), UserId(uid)).await?.user.full_name();
let mention = user_mention(UserId(uid), &name);
str.push_str(&format!("\n - {mention}\nНа посту с {}, UID: {uid}", col.created_at.format("%Y-%m-%d %H:%M:%S")));
};
tracing::info!("Sending message! {str}");
bot.send_message(msg.chat.id, str).parse_mode(ParseMode::Html).await?
} else {
bot.send_message(msg.chat.id, "Модераторов нет").await?
};
},
Command::AddMod => {
bot.send_message(msg.chat.id, "Перешлите любое сообщение от человека которого вы хотите добавить как модератора:").reply_markup(inline_cancel()).await?;
dialogue.update(DialogueState::NewModeratorInput).await?;
},
Command::RemMod(uid) => {
if uid.is_empty() {
bot.send_message(msg.chat.id, "После команды необходимо указать UID модератора. (/remmod 1234567)").await?;
} else {
bot.send_message(msg.chat.id, "Вы уверены что хотите удалить модератора?").reply_markup(inline_yes_or_no()).await?;
dialogue.update(DialogueState::RemoveModeratorConfirm { uid }).await?;
}
},
Command::About => {
bot.send_message(msg.chat.id, about_msg(&rights)).await?;
},
};
Ok(())
}
fn recognise_vid(text: &str) -> Option<i32> {
if let Ok(vid) = text.parse::<i32>() {
Some(vid)
} else {
if let Some(unslash) = text.strip_prefix("/") {
if let Ok(vid) = unslash.parse::<i32>() {
Some(vid)
} else {
None
}
} else {
None
}
}
}
async fn info(bot: Bot, msg: Message, state: Arc<AppState>) -> anyhow::Result<()> {
use entity::{videos, actions};
use youtube::DEFAULT_YT;
let vid = recognise_vid(msg.text().unwrap()).unwrap(); // Проверено в dptree
let col = videos::Entity::find_by_id(vid).one(&state.db).await?;
if let Some(video) = col {
// Getting creator from actions
let creator = actions::Entity::find()
.filter(actions::Column::Vid.eq(video.id))
.order_by(actions::Column::Id, Order::Asc)
.one(&state.db).await?
.ok_or(anyhow::anyhow!("Can't find creator entry for {video:?}"))?;
let contributors = actions::Entity::find().filter(actions::Column::Vid.eq(video.id)).count(&state.db).await?;
let creator_uid = creator.uid.parse()?;
let name = bot.get_chat_member(ChatId(creator_uid as i64), UserId(creator_uid)).await?.user.full_name();
let creator_mention = user_mention(UserId(creator_uid), &name);
let out: String = format!(
"<a href=\"{DEFAULT_YT}{}\">{}</a>\n\
Добавлено {creator_mention} (👀{contributors})"
, video.yt_id, video.title);
// TODO: УБЕДИТСЯ ЧТО НЕ ТРЕБУЕТСЯ https://docs.rs/teloxide/latest/teloxide/types/struct.LinkPreviewOptions.html
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
vec![InlineKeyboardButton::callback("Просмотрено", format!("viewed {}", video.id)), InlineKeyboardButton::callback("В бан", format!("ban {}", video.id))]
];
bot.send_message(msg.chat.id, out).parse_mode(ParseMode::Html).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
} else {
bot.send_message(msg.chat.id, "Не найдено.").await?;
}
Ok(())
}
async fn insufficient_rights(bot: Bot, msg: Message, cmd: Command) -> anyhow::Result<()> {
let rights = Rights::None;
if let Some(user) = check_subscription(&bot, &msg.from.ok_or(anyhow::anyhow!("Message not from user!"))?.id).await {
match cmd {
Command::Start => {
bot.send_sticker(
msg.chat.id,
InputFile::file_id("CAACAgIAAxkBAAECxFlnVeGjr8kRcDNWU30uDII5R1DwNAACKl4AAkxE8UmPev9DDR6RgTYE"))
.emoji("🥳")
.await?;
bot.send_message(msg.chat.id, format!(
"Приветствую {}!\n\
Отправьте в этот чат ссылку на YouTube видео, чтобы предложить его для просмотра!",
user.full_name()
)).await?;
},
Command::About => {
bot.send_message(msg.chat.id, about_msg(&rights)).await?;
},
_ => {
bot.send_message(msg.chat.id, format!(
"?"
)).await?;
}
}
} else {
bot.send_message(msg.chat.id, format!(
"Вы не подписаны на Telegram канал!"
)).await?;
}
Ok(())
}
async fn normal_answer(bot: Bot, msg: Message, dialogue: MyDialogue) -> anyhow::Result<()> {
use youtube::*;
if let Some(text) = msg.clone().text() {
if let Some(user) = check_subscription(&bot, &msg.from.ok_or(anyhow::anyhow!("Message not from user!"))?.id).await {
// Get ready!
if let Some(ytid) = extract_youtube_video_id(text) {
let meta = get_video_metadata(&ytid).await?;
// Post
bot.send_message(msg.chat.id, format!(
"Вы уверены что хотите добавить <b>{}</b>",
meta.title
)).parse_mode(ParseMode::Html).reply_markup(inline_yes_or_no()).await?;
let accept = ForAccept { ytid, uid: user.id.0, title: meta.title };
dialogue.update(DialogueState::AcceptVideo { accept }).await?;
} else {
bot.send_message(msg.chat.id, "Это не похоже на YouTube видео... Долбоёб").await?;
}
} else {
bot.send_message(msg.chat.id, "Вы не подписаны на Telegram канал!").await?;
}
} else {
bot.send_message(msg.chat.id, "Не-а!").await?;
}
Ok(())
}
fn about_msg(rights: &Rights) -> String {
format!(
"Doggy-Watch v{VERSION}\n\
____________________\n\
Debug information:\n\
Rights level: {rights:?}\n\
Linked channel: {}\n\
Cooldown duration: {:?}",
*CHANNEL, COOLDOWN_DURATION
)
}
async fn add_moderator_from_recived_message(bot: Bot, msg: Message, state: Arc<AppState>, dialogue: MyDialogue) -> anyhow::Result<()> {
use entity::moderators::ActiveModel;
if let Some(user) = msg.forward_from_user() {
let member = check_subscription(&bot, &user.id).await;
if let Some(user) = member {
let now = Local::now().naive_local();
let model = ActiveModel {
uid: Set(user.id.0.to_string()),
created_at: Set(now),
};
if model.insert(&state.db).await.is_ok() {
bot.send_message(msg.chat.id, "Модератор добавлен!").await?;
} else {
bot.send_message(msg.chat.id, "Произошла ошибка!\nМожет данный модератор уже добавлен?").await?;
}
dialogue.exit().await?;
} else { bot.send_message(msg.chat.id, "Ошибка! Не подписан на канал!").await?; }
} else { bot.send_message(msg.chat.id, "Ошибка! Перешлите сообщение!").await?; }
Ok(())
}
// ------------------------
// INLINE
// ------------------------
fn inline_yes_or_no() -> InlineKeyboardMarkup {
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
vec![InlineKeyboardButton::callback("Да", "yes"), InlineKeyboardButton::callback("Нет", "no")]
];
InlineKeyboardMarkup::new(keyboard)
}
fn inline_cancel() -> InlineKeyboardMarkup {
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
vec![InlineKeyboardButton::callback("Отменить", "cancel")]
];
InlineKeyboardMarkup::new(keyboard)
}
async fn change_status(bot: Bot, q: CallbackQuery, state: Arc<AppState>) -> anyhow::Result<()> {
use entity::{videos::{ActiveModel, Entity}, sea_orm_active_enums::Status};
bot.answer_callback_query(&q.id).await?;
if let Some(msg) = q.regular_message() {
if let Some(data) = q.clone().data {
// ..
let data: Vec<&str> = data.split(" ").collect();
let text = if data.len() == 2 {
let status = match data[0] {
"ban" => {
Status::Banned
},
"viewed" => {
Status::Viewed
}
_ => {
anyhow::bail!("Unrecognized status! {data:?}");
}
};
let vid: i32 = data[1].parse()?;
let video = Entity::find_by_id(vid).one(&state.db).await?;
if let Some(model) = video {
let title = model.title.clone();
let mut video: ActiveModel = model.into();
video.status = Set(status);
video.updated_at = Set(Some(Local::now().naive_local()));
if video.update(&state.db).await.is_ok() {
&format!("Статус видео <b>\"{title}\"</b> успешно обновлён!")
} else {
"Произошла ошибка обновления записи в базе данных!"
}
} else {
"Не найдено."
}
} else {
"Ошибка распознавания"
};
bot.send_message(msg.chat_id().unwrap(), text).parse_mode(ParseMode::Html).await?;
// else if let Some(id) = q.inline_message_id {
// bot.edit_message_text_inline(id, text).await?;
// }
}
}
Ok(())
}
async fn remove_moderator(bot: Bot, q: CallbackQuery, state: Arc<AppState>, uid: String, dialogue: MyDialogue) -> anyhow::Result<()> {
use entity::moderators::Entity;
bot.answer_callback_query(&q.id).await?;
if let Some(msg) = q.regular_message() {
if let Some(data) = q.clone().data {
let text= if &data == "yes" {
if Entity::delete_by_id(uid).exec(&state.db).await?.rows_affected != 0 {
"Модератор удалён!"
} else {
"Произошла ошибка!\nПо всей видимости такого модератора не существует."
}
} else {
"Раскулачивание модера отменено."
};
bot.edit_message_text(msg.chat_id().unwrap(), msg.id, text).await?;
// else if let Some(id) = q.inline_message_id {
// bot.edit_message_text_inline(id, text).await?;
// }
}
}
dialogue.exit().await?;
Ok(())
}
async fn accept_video(bot: Bot, q: CallbackQuery, state: Arc<AppState>, accept: ForAccept, dialogue: MyDialogue) -> anyhow::Result<()> {
use entity::{videos, actions, sea_orm_active_enums::Status};
bot.answer_callback_query(&q.id).await?;
if let Some(msg) = q.regular_message() {
if let Some(data) = q.clone().data {
let text= if &data == "yes" {
if let Some(last) = state.cooldown.get(&accept.uid) {
if last.elapsed() < COOLDOWN_DURATION {
bot.edit_message_text(msg.chat_id().unwrap(), msg.id, "Боже... Ты слишком груб с этим ботом. Остуди пыл.").await?;
dialogue.exit().await?;
return Ok(());
}
}
let video = if let Some(video ) = videos::Entity::find().filter(videos::Column::YtId.eq(accept.ytid.clone())).one(&state.db).await? {
Ok(video)
} else {
let video= videos::ActiveModel {
title: Set(accept.title),
yt_id: Set(accept.ytid),
created_at: Set(Local::now().naive_local()),
status: Set(Status::Pending),
..Default::default()
};
video.insert(&state.db).await
};
if let Ok(video) = video {
if let Ok(duplicates) = actions::Entity::find().filter(actions::Column::Uid.eq(accept.uid.to_string())).filter(actions::Column::Vid.eq(video.id)).count(&state.db).await {
if duplicates == 0 {
let action= actions::ActiveModel {
uid: Set(accept.uid.to_string()),
vid: Set(video.id),
created_at: Set(Local::now().naive_local()),
..Default::default()
};
if action.insert(&state.db).await.is_ok() {
state.cooldown.insert(accept.uid, Instant::now());
"Добавлено!"
} else {
videos::Entity::delete_by_id(video.id).exec(&state.db).await?;
"База данных вернула ошибку на этапе создания события!"
}
} else {
"Отправлять одно и тоже видео нельзя!"
}
} else {
"База данных вернула ошибку на этапе проверки дублекатов!"
}
} else {
"База данных вернула ошибку на этапе создания видео!"
}
} else {
"Отменено."
};
bot.edit_message_text(msg.chat_id().unwrap(), msg.id, text).await?;
// else if let Some(id) = q.inline_message_id {
// bot.edit_message_text_inline(id, text).await?;
// }
}
}
dialogue.exit().await?;
Ok(())
}
async fn cancel(bot: Bot, q: CallbackQuery, dialogue: MyDialogue) -> anyhow::Result<()> {
bot.answer_callback_query(&q.id).await?;
dialogue.exit().await?;
Ok(())
}
// ------------------------
// FACE CONTROL
// ------------------------
#[derive(Debug)]
enum Rights {
None,
Moderator,
Administrator
}
impl From<Rights> for bool {
fn from(value: Rights) -> Self {
match value {
Rights::None => false,
_ => true
}
}
}
async fn check_subscription(bot: &Bot, uid: &UserId) -> Option<User> {
let chat_member = bot
.get_chat_member(ChatId(*CHANNEL), *uid).send().await;
match chat_member {
Ok(member) => {
let kind = member.kind;
tracing::debug!("{uid}: {kind:?}");
if match kind {
teloxide::types::ChatMemberKind::Owner(_owner) => true,
teloxide::types::ChatMemberKind::Administrator(_administrator) => true,
teloxide::types::ChatMemberKind::Member => true,
teloxide::types::ChatMemberKind::Restricted(_restricted) => true,
teloxide::types::ChatMemberKind::Left => false,
teloxide::types::ChatMemberKind::Banned(_banned) => false,
} {
Some(member.user)
} else {
None
}
},
Err(_) => None,
}
}

11
youtube/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "youtube"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.42", features = ["full"] }
url = "2.5.4"

90
youtube/src/lib.rs Normal file
View file

@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Serialize, Deserialize)]
pub struct VideoMetadata {
pub title: String,
}
pub const DEFAULT_YT: &str = "https://youtu.be/";
pub fn extract_youtube_video_id(url: &str) -> Option<String> {
if let Ok(parsed_url) = Url::parse(url) {
// Check if the URL is from YouTube or YouTube short URL
if parsed_url.host_str() == Some("youtube.com") || parsed_url.host_str() == Some("www.youtube.com") || parsed_url.host_str() == Some("music.youtube.com") {
// Extract the video ID from the query parameters
if let Some(query) = parsed_url.query() {
let params: std::collections::HashMap<_, _> = query.split('&')
.filter_map(|param| {
let (key, value) = param.split_once('=')?;
Some((key, value))
})
.collect();
if let Some(video_id) = params.get("v") {
return Some(video_id.to_string());
}
}
} else if parsed_url.host_str() == Some("youtu.be") {
// Extract the video ID from the path
if let Some(mut path) = parsed_url.path_segments() {
if let Some(video_id) = path.next() {
return Some(video_id.to_string());
}
}
}
}
None
}
pub async fn get_video_metadata(vid: &str) -> Result<VideoMetadata, reqwest::Error> {
let response = reqwest::get(format!("https://www.youtube.com/oembed?url={DEFAULT_YT}{vid}")).await?;
if !response.status().is_success() {
return Err(response.error_for_status().unwrap_err());
}
let metadata: VideoMetadata = response.json().await?;
Ok(metadata)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_youtube_video_id_youtu_be() {
let url = "https://youtu.be/VJFNcHgQ4HM?si=SvWeZZC_UjA1Nhon";
assert_eq!(extract_youtube_video_id(url), Some("VJFNcHgQ4HM".to_string()));
}
#[test]
fn test_extract_youtube_video_id_youtube_com() {
let url = "https://www.youtube.com/watch?v=VJFNcHgQ4HM&amp;list=RDCt2h5Xj41Ss&amp;index=2";
assert_eq!(extract_youtube_video_id(url), Some("VJFNcHgQ4HM".to_string()));
}
#[test]
fn test_extract_youtube_video_id_music_youtube_com() {
let url = "https://music.youtube.com/watch?v=rfDBTQNdj-M&list=OLAK5uy_nGaGJk4vjvgxE0ff5T9Qus-WEEBYowbBw";
assert_eq!(extract_youtube_video_id(url), Some("rfDBTQNdj-M".to_string()));
}
#[test]
fn test_extract_youtube_video_id_youtube_com_no_query() {
let url = "https://www.youtube.com/watch";
assert_eq!(extract_youtube_video_id(url), None);
}
#[test]
fn test_extract_youtube_video_id_invalid_url() {
let url = "https://example.com/watch?v=VJFNcHgQ4HM";
assert_eq!(extract_youtube_video_id(url), None);
}
#[test]
fn test_extract_youtube_video_id_empty_url() {
let url = "";
assert_eq!(extract_youtube_video_id(url), None);
}
}