Compare commits

..

No commits in common. "master" and "v0.2.1" have entirely different histories.

12 changed files with 282 additions and 468 deletions

493
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package] [package]
name = "doggy-watch" name = "doggy-watch"
authors = ["Shiroyashik <shiroyashik@shsr.ru>"] authors = ["Shiroyashik <shiroyashik@shsr.ru>"]
version = "0.2.2" version = "0.2.0"
edition = "2021" edition = "2021"
publish = false publish = false
@ -26,7 +26,6 @@ tracing-panic = "0.1"
lazy_static = "1.5" lazy_static = "1.5"
indexmap = "2.7" indexmap = "2.7"
dashmap = "6.1" dashmap = "6.1"
url = "2.5"
# https://github.com/teloxide/teloxide/issues/1154 # https://github.com/teloxide/teloxide/issues/1154
# [profile.dev] # [profile.dev]

View file

@ -1,6 +1,6 @@
## Chef ## Chef
# FROM clux/muslrust:stable AS chef # FROM clux/muslrust:stable AS chef
FROM rust:alpine AS chef FROM rust:1.84.0-alpine3.20 AS chef
USER root USER root
RUN apk add --no-cache musl-dev libressl-dev RUN apk add --no-cache musl-dev libressl-dev
RUN cargo install cargo-chef RUN cargo install cargo-chef

View file

@ -35,10 +35,6 @@ ID канала для проверки подписки.
`trace, debug, info, warn, error` `trace, debug, info, warn, error`
Также можно указать отдельный уровень логирования для отдельных целей. Также можно указать отдельный уровень логирования для отдельных целей.
`TELEGRAM_API_URL=<url>`
Сторонний Telegram Bot API сервер (необязательно).
### Только для Docker ### Только для Docker
`TZ=<TZ_identifier>` `TZ=<TZ_identifier>`

View file

@ -108,22 +108,22 @@ impl MigrationTrait for Migration {
} }
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Actions // Videos
manager manager
.drop_table(Table::drop().table(Actions::Table).to_owned()) .drop_table(Table::drop().table(Videos::Table).to_owned())
.await?; .await?;
// Requests // Requests
manager manager
.drop_table(Table::drop().table(Requests::Table).to_owned()) .drop_table(Table::drop().table(Requests::Table).to_owned())
.await?; .await?;
// Actions
manager
.drop_table(Table::drop().table(Actions::Table).to_owned())
.await?;
// Archived // Archived
manager manager
.drop_table(Table::drop().table(Archived::Table).to_owned()) .drop_table(Table::drop().table(Archived::Table).to_owned())
.await?; .await?;
// Videos
manager
.drop_table(Table::drop().table(Videos::Table).to_owned())
.await?;
// Moderators // Moderators
manager manager
.drop_table(Table::drop().table(Moderators::Table).to_owned()) .drop_table(Table::drop().table(Moderators::Table).to_owned())

View file

@ -10,17 +10,10 @@ use crate::{check_subscription, markup, notify, AppState, DialogueState, MyDialo
pub async fn message(bot: Bot, msg: Message, dialogue: MyDialogue) -> anyhow::Result<()> { pub async fn message(bot: Bot, msg: Message, dialogue: MyDialogue) -> anyhow::Result<()> {
use youtube::*; use youtube::*;
if let Some(text) = msg.clone().text() { if let Some(text) = msg.clone().text() {
if let Some(user) = check_subscription(&bot, &msg.clone().from.ok_or(anyhow::anyhow!("Message not from user!"))?.id).await { if let Some(user) = check_subscription(&bot, &msg.from.ok_or(anyhow::anyhow!("Message not from user!"))?.id).await {
// Get ready! // Get ready!
if let Some(ytid) = extract_youtube_video_id(text) { if let Some(ytid) = extract_youtube_video_id(text) {
let meta = match get_video_metadata(&ytid).await { let meta = get_video_metadata(&ytid).await?;
Ok(meta) => meta,
Err(err) => {
tracing::error!("Caused an exception in get_video_metadata due: {err:?}");
bot.send_message(msg.chat.id, "Ошибка при получении метаданных видео!").await?;
return Ok(());
},
};
// Post // Post
bot.send_message(msg.chat.id, format!( bot.send_message(msg.chat.id, format!(
"Вы уверены что хотите добавить <b>{}</b>", "Вы уверены что хотите добавить <b>{}</b>",
@ -28,7 +21,6 @@ pub async fn message(bot: Bot, msg: Message, dialogue: MyDialogue) -> anyhow::Re
)).parse_mode(ParseMode::Html).reply_markup(markup::inline_yes_or_no()).await?; )).parse_mode(ParseMode::Html).reply_markup(markup::inline_yes_or_no()).await?;
dialogue.update(DialogueState::AcceptVideo { ytid, uid: user.id.0, title: meta.title }).await?; dialogue.update(DialogueState::AcceptVideo { ytid, uid: user.id.0, title: meta.title }).await?;
} else { } else {
tracing::debug!("Not a YouTube video: {:?}", msg);
bot.send_message(msg.chat.id, "Это не похоже на YouTube видео... Долбоёб").await?; bot.send_message(msg.chat.id, "Это не похоже на YouTube видео... Долбоёб").await?;
} }
} else { } else {

View file

@ -1,161 +1,84 @@
use std::sync::Arc; use std::sync::Arc;
use indexmap::IndexMap; use indexmap::IndexMap;
use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions, ParseMode}}; use teloxide::{prelude::*, types::{LinkPreviewOptions, ParseMode}};
use sea_orm::{prelude::*, Order, QueryOrder}; use sea_orm::{prelude::*, Order, QueryOrder};
use database::*; use database::*;
use crate::AppState; use crate::AppState;
struct Video {
id: i32,
title: String,
url: String,
contributors: u64,
status: String,
}
pub async fn command(bot: Bot, msg: Message, state: Arc<AppState>) -> anyhow::Result<()> { pub async fn command(bot: Bot, msg: Message, state: Arc<AppState>) -> anyhow::Result<()> {
struct Video {
id: i32,
title: String,
url: String,
contributors: u64,
status: String,
}
let videos: Vec<(requests::Model, Option<videos::Model>)> = requests::Entity::find() let videos: Vec<(requests::Model, Option<videos::Model>)> = requests::Entity::find()
.find_also_related(videos::Entity).filter(videos::Column::Banned.eq(false)).all(&state.db).await?; .find_also_related(videos::Entity).filter(videos::Column::Banned.eq(false)).all(&state.db).await?;
// let videos_len = videos.len();
let result = generate_list(videos, &state).await; if !videos.is_empty() {
match result { let mut by_date: IndexMap<Date, Vec<Video>> = IndexMap::new();
Ok(list) => { for (request, video) in videos {
let result = if let Some(list) = list { let video = video.unwrap();
list let creator = if let Some(c) = request.find_related(actions::Entity).order_by(actions::Column::Id, Order::Asc).one(&state.db).await? {
c
} else { } else {
"Нет видео для просмотра :(".to_string() let data = format!("Can't find creator for {request:?}");
bot.send_message(msg.chat.id, data.clone()).await?;
anyhow::bail!(data);
}; };
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![ let contributors = request.find_related(actions::Entity).count(&state.db).await?;
vec![InlineKeyboardButton::callback("Непросмотренные", "list_unviewed")], let date = creator.created_at.date();
]; let url = format!("{}{}", youtube::DEFAULT_YT, video.ytid);
bot.send_message(msg.chat.id, result).parse_mode(ParseMode::Html) let viewed_times = archived::Entity::find().filter(archived::Column::Ytid.eq(video.ytid.clone())).filter(archived::Column::ViewedAt.is_not_null()).count(&state.db).await?;
.link_preview_options(LinkPreviewOptions { let archived_times = archived::Entity::find().filter(archived::Column::Ytid.eq(video.ytid)).count(&state.db).await?;
is_disabled: true,
url: None,
prefer_small_media: false,
prefer_large_media: false,
show_above_text: false
}).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
},
Err(e) => {
tracing::error!("{:?}", e);
bot.send_message(msg.chat.id, "Произошла ошибка!").await?;
},
}
Ok(())
}
pub async fn inline(state: Arc<AppState>, bot: Bot, q: CallbackQuery) -> anyhow::Result<()> { let mut status = String::new();
bot.answer_callback_query(&q.id).await?; status.push(if request.viewed_at.is_some() {
let videos: Vec<(requests::Model, Option<videos::Model>)> = requests::Entity::find() '👀'
.find_also_related(videos::Entity).filter(videos::Column::Banned.eq(false)).filter(requests::Column::ViewedAt.is_null()).all(&state.db).await?; } else if viewed_times != 0 {
let result = generate_list(videos, &state).await; '⭐'
match result { } else if archived_times != 0 {
Ok(list) => { '📁'
let result = if let Some(list) = list {
list
} else { } else {
"Нет видео для просмотра :(".to_string() '🆕'
});
if let Some(entry) = by_date.get_mut(&date) {
entry.push(Video { id: request.id, title: video.title, url, contributors, status });
} else {
by_date.insert(date, vec![Video { id: request.id, title: video.title, url, contributors, status }]);
}; };
}
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![ by_date.sort_unstable_by(|a, _, c, _| c.cmp(a));
vec![InlineKeyboardButton::callback("Обновить", "list_unviewed")], let mut result = String::new();
]; for (date, mut videos) in by_date {
if result.is_empty() {
if let Some(message) = q.regular_message() { result.push_str(&format!("[{}]", date.format("%d.%m")));
bot.edit_message_text(message.chat.id, message.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
}).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
} else if let Some(message_id) = q.inline_message_id {
bot.edit_message_text_inline(&message_id, result)
.parse_mode(ParseMode::Html).disable_web_page_preview(true).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
} else { } else {
bot.send_message(q.from.id, result).parse_mode(ParseMode::Html) result.push_str(&format!("\n[{}]", date.format("%d.%m")));
.reply_markup(InlineKeyboardMarkup::new(keyboard))
.link_preview_options(LinkPreviewOptions {
is_disabled: true,
url: None,
prefer_small_media: false,
prefer_large_media: false,
show_above_text: false
}).await?;
} }
}, // result.push_str(&format!(" {}", videos.len()));
Err(e) => { videos.sort_unstable_by(|a, b| a.contributors.cmp(&b.contributors));
tracing::error!("{:?}", e); for video in videos {
bot.send_message(q.from.id, "Произошла ошибка!").await?; let contributors = if video.contributors != 1 {
}, format!("(🙍‍♂️{}) ", video.contributors)
} else {
String::new()
};
result.push_str(&format!("\n{}/{} <a href=\"{}\">📺YT</a> {}<b>{}</b>", video.status, video.id, video.url, 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));
}
}
// result.push_str(&format!("\nВсего: {}", videos_len));
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?;
} }
Ok(()) Ok(())
} }
async fn generate_list(videos: Vec<(requests::Model, Option<videos::Model>)>, state: &AppState) -> anyhow::Result<Option<String>> {
if videos.is_empty() {
return Ok(None);
}
let mut by_date: IndexMap<Date, Vec<Video>> = IndexMap::new();
for (request, video) in videos {
let video = video.unwrap();
let creator = if let Some(c) = request.find_related(actions::Entity).order_by(actions::Column::Id, Order::Asc).one(&state.db).await? {
c
} else {
anyhow::bail!("Can't find creator for {request:?}");
};
let contributors = request.find_related(actions::Entity).count(&state.db).await?;
let date = creator.created_at.date();
let url = format!("{}{}", youtube::DEFAULT_YT, video.ytid);
let viewed_times = archived::Entity::find().filter(archived::Column::Ytid.eq(video.ytid.clone())).filter(archived::Column::ViewedAt.is_not_null()).count(&state.db).await?;
let archived_times = archived::Entity::find().filter(archived::Column::Ytid.eq(video.ytid)).count(&state.db).await?;
let mut status = String::new();
status.push(if request.viewed_at.is_some() {
'👀'
} else if viewed_times != 0 {
'⭐'
} else if archived_times != 0 {
'📁'
} else {
'🆕'
});
if let Some(entry) = by_date.get_mut(&date) {
entry.push(Video { id: request.id, title: video.title, url, contributors, status });
} else {
by_date.insert(date, vec![Video { id: request.id, title: video.title, url, contributors, status }]);
};
}
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("%d.%m")));
} else {
result.push_str(&format!("\n[{}]", date.format("%d.%m")));
}
// result.push_str(&format!(" {}", videos.len()));
videos.sort_unstable_by(|a, b| a.contributors.cmp(&b.contributors));
for video in videos {
let contributors = if video.contributors != 1 {
format!("(🙍‍♂️{}) ", video.contributors)
} else {
String::new()
};
result.push_str(&format!("\n{}/{} <a href=\"{}\">📺YT</a> {}<b>{}</b>", video.status, video.id, video.url, 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));
}
}
// result.push_str(&format!("\nВсего: {}", videos_len));
Ok(Some(result))
}

View file

@ -19,7 +19,6 @@ pub fn schema() -> UpdateHandler<anyhow::Error> {
use dptree::case; use dptree::case;
let moderator_commands = dptree::entry() let moderator_commands = dptree::entry()
.branch(case![Command::Start].endpoint(start::command_mod)) .branch(case![Command::Start].endpoint(start::command_mod))
.branch(case![Command::Help].endpoint(start::command_mod))
.branch(case![Command::List].endpoint(list::command)) .branch(case![Command::List].endpoint(list::command))
.branch(case![Command::Archive].endpoint(archive::command)) .branch(case![Command::Archive].endpoint(archive::command))
.branch(case![Command::Mods].endpoint(moderator::list::command)) .branch(case![Command::Mods].endpoint(moderator::list::command))
@ -74,7 +73,6 @@ pub fn schema() -> UpdateHandler<anyhow::Error> {
InlineCommand::parse(&q.data?) InlineCommand::parse(&q.data?)
})) }))
.branch(case![InlineCommand::Cancel].endpoint(cancel)) .branch(case![InlineCommand::Cancel].endpoint(cancel))
.branch(case![InlineCommand::ListUnviewed].endpoint(list::inline))
.branch(filter(|com: InlineCommand| { .branch(filter(|com: InlineCommand| {
matches!(com, InlineCommand::ArchiveAll | InlineCommand::ArchiveViewed) matches!(com, InlineCommand::ArchiveAll | InlineCommand::ArchiveViewed)
}).endpoint(archive::inline)) }).endpoint(archive::inline))

View file

@ -29,7 +29,7 @@ pub async fn inline(bot: Bot, q: CallbackQuery, state: Arc<AppState>, uid: Strin
if let Some(data) = q.clone().data { if let Some(data) = q.clone().data {
let text= if &data == "yes" { let text= if &data == "yes" {
if let Ok(uid) = uid.parse::<u64>() { if let Ok(uid) = uid.parse::<u64>() {
if Entity::delete_by_id(uid as i64).exec(&state.db).await?.rows_affected != 0 { if Entity::delete_by_id(uid as i32).exec(&state.db).await?.rows_affected != 0 {
"Модератор удалён!" "Модератор удалён!"
} else { } else {
"Произошла ошибка!\nПо всей видимости такого модератора не существует." "Произошла ошибка!\nПо всей видимости такого модератора не существует."

View file

@ -8,7 +8,7 @@ use crate::AppState;
/// Invert notify status for moderator /// Invert notify status for moderator
pub async fn command(bot: Bot, msg: Message, uid: UserId, state: Arc<AppState>) -> anyhow::Result<()> { pub async fn command(bot: Bot, msg: Message, uid: UserId, state: Arc<AppState>) -> anyhow::Result<()> {
let text = if let Some(moder) = moderators::Entity::find_by_id(uid.0 as i64).one(&state.db).await? { let text = if let Some(moder) = moderators::Entity::find_by_id(uid.0 as i32).one(&state.db).await? {
let moder = match moder.notify { let moder = match moder.notify {
true => { true => {
let mut moder = moder.into_active_model(); let mut moder = moder.into_active_model();

View file

@ -6,7 +6,6 @@ pub enum InlineCommand {
Unview(i32), Unview(i32),
ArchiveViewed, ArchiveViewed,
ArchiveAll, ArchiveAll,
ListUnviewed,
Cancel, Cancel,
} }
@ -20,7 +19,6 @@ impl InlineCommand {
"unview" => Self::Unview(parts.next()?.parse().ok()?), "unview" => Self::Unview(parts.next()?.parse().ok()?),
"archive_viewed" => Self::ArchiveViewed, "archive_viewed" => Self::ArchiveViewed,
"archive_all" => Self::ArchiveAll, "archive_all" => Self::ArchiveAll,
"list_unviewed" => Self::ListUnviewed,
"cancel" => Self::Cancel, "cancel" => Self::Cancel,
_ => return None, _ => return None,
}) })

View file

@ -17,7 +17,6 @@ mod markup;
mod inline; mod inline;
pub use inline::InlineCommand; pub use inline::InlineCommand;
use url::Url;
pub const COOLDOWN_DURATION: Duration = Duration::from_secs(10); pub const COOLDOWN_DURATION: Duration = Duration::from_secs(10);
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -29,12 +28,6 @@ lazy_static! {
pub static ref TOKEN: String = { pub static ref TOKEN: String = {
var("TOKEN").expect("TOKEN env not set.") var("TOKEN").expect("TOKEN env not set.")
}; };
pub static ref TELEGRAM_API_URL: Url = {
match var("TELEGRAM_API_URL") {
Ok(url) => url.parse().expect("Can't parse TELEGRAM_API_URL"),
Err(_) => teloxide::net::TELEGRAM_API_URL.parse().expect("Failed to parse default Telegram bot API url")
}
};
pub static ref DATABASE_URL: String = { pub static ref DATABASE_URL: String = {
var("DATABASE_URL").expect("DATABASE_URL env not set.") var("DATABASE_URL").expect("DATABASE_URL env not set.")
}; };
@ -67,8 +60,8 @@ async fn main() -> anyhow::Result<()> {
})); }));
tracing::info!("Doggy-Watch v{VERSION}"); tracing::info!("Doggy-Watch v{VERSION}");
tracing::info!("admins: {:?} tg api: {}", *ADMINISTRATORS, TELEGRAM_API_URL.as_str()); tracing::info!("{:?}", *ADMINISTRATORS);
let bot = Bot::new(&*TOKEN).set_api_url(TELEGRAM_API_URL.clone()); let bot = Bot::new(&*TOKEN);
let mut opt = ConnectOptions::new(&*DATABASE_URL); let mut opt = ConnectOptions::new(&*DATABASE_URL);
opt.sqlx_logging_level(tracing::log::LevelFilter::Trace); opt.sqlx_logging_level(tracing::log::LevelFilter::Trace);
@ -100,7 +93,7 @@ async fn main() -> anyhow::Result<()> {
// Pass the shared state to the handler as a dependency. // Pass the shared state to the handler as a dependency.
.dependencies(dptree::deps![state, InMemStorage::<DialogueState>::new()]) .dependencies(dptree::deps![state, InMemStorage::<DialogueState>::new()])
.default_handler(|upd| async move { .default_handler(|upd| async move {
tracing::debug!("Unhandled update: {:?}", upd); tracing::warn!("Unhandled update: {:?}", upd);
}) })
.enable_ctrlc_handler() .enable_ctrlc_handler()
.build() .build()
@ -127,8 +120,6 @@ pub enum DialogueState {
enum Command { enum Command {
#[command(description = "запустить бота и/или вывести этот текст.")] #[command(description = "запустить бота и/или вывести этот текст.")]
Start, Start,
#[command(description = "вывести этот текст.")]
Help,
#[command(description = "вывести список.")] #[command(description = "вывести список.")]
List, List,
#[command(description = "действия с архивом.")] #[command(description = "действия с архивом.")]
@ -193,7 +184,7 @@ impl AppState {
async fn check_rights(&self, uid: &UserId) -> anyhow::Result<Rights> { async fn check_rights(&self, uid: &UserId) -> anyhow::Result<Rights> {
use database::moderators::Entity as Moderators; use database::moderators::Entity as Moderators;
Ok(if let Some(moder) = Moderators::find_by_id(uid.0 as i64).one(&self.db).await? { Ok(if let Some(moder) = Moderators::find_by_id(uid.0 as i32).one(&self.db).await? {
Rights::Moderator { can_add_mods: moder.can_add_mods } Rights::Moderator { can_add_mods: moder.can_add_mods }
} else { } else {
Rights::None Rights::None