mirror of
https://github.com/shiroyashik/doggy-watch.git
synced 2025-12-06 12:31:13 +03:00
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dca70ba2cc | |||
| fa125b47fb | |||
| 655e195c91 | |||
| 3132ebd036 | |||
| c84385bc68 | |||
| 591020f04b | |||
| 5bcfff99a1 |
18 changed files with 604 additions and 290 deletions
42
.github/workflows/docker-push.yml
vendored
Normal file
42
.github/workflows/docker-push.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: Docker Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# - name: Checkout code
|
||||||
|
# uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# - name: Login to Docker Hub
|
||||||
|
# uses: docker/login-action@v3
|
||||||
|
# with:
|
||||||
|
# username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get short SHA
|
||||||
|
id: short_sha
|
||||||
|
run: echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
# context: .
|
||||||
|
tags: ghcr.io/${{ github.repository_owner }}/doggy-watch:${{ steps.short_sha.outputs.sha }},ghcr.io/${{ github.repository_owner }}/doggy-watch:latest
|
||||||
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/doggy-watch:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/doggy-watch:buildcache,mode=max
|
||||||
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
/act
|
/act
|
||||||
.env
|
.env
|
||||||
note.txt
|
note.txt
|
||||||
|
docker-compose.yaml
|
||||||
496
Cargo.lock
generated
496
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,17 @@
|
||||||
[package]
|
[package]
|
||||||
name = "doggy-watch"
|
name = "doggy-watch"
|
||||||
authors = ["Shiroyashik <shiroyashik@shsr.ru>"]
|
authors = ["Shiroyashik <shiroyashik@shsr.ru>"]
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [ "database", "youtube", "database/migration" ]
|
members = [ "database", "youtube", "migration" ]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
database = { path = "database" }
|
database = { path = "database" }
|
||||||
youtube = { path = "youtube" }
|
youtube = { path = "youtube" }
|
||||||
|
migration = { path = "migration"}
|
||||||
|
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|
@ -25,6 +26,7 @@ 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]
|
||||||
|
|
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
## Chef
|
||||||
|
# FROM clux/muslrust:stable AS chef
|
||||||
|
FROM rust:alpine AS chef
|
||||||
|
USER root
|
||||||
|
RUN apk add --no-cache musl-dev libressl-dev
|
||||||
|
RUN cargo install cargo-chef
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
## Planner
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY ./migration ./migration
|
||||||
|
COPY ./database ./database
|
||||||
|
COPY ./youtube ./youtube
|
||||||
|
COPY src src
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
## Builder
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /build/recipe.json recipe.json
|
||||||
|
# Build dependencies - this is the caching Docker layer!
|
||||||
|
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
|
||||||
|
# Build application
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY ./migration ./migration
|
||||||
|
COPY ./database ./database
|
||||||
|
COPY ./youtube ./youtube
|
||||||
|
COPY src src
|
||||||
|
RUN cargo build --release --target x86_64-unknown-linux-musl --bin doggy-watch
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
FROM alpine:3.20.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/doggy-watch /app/doggy-watch
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
ENV TZ=Etc/UTC
|
||||||
|
|
||||||
|
ENTRYPOINT [ "./doggy-watch" ]
|
||||||
46
README.md
46
README.md
|
|
@ -3,14 +3,58 @@
|
||||||
Telegram бот для предложения YouTube видео на стрим.
|
Telegram бот для предложения YouTube видео на стрим.
|
||||||
Сделан специально для [Doggy Dox](https://www.twitch.tv/doggy_dox).
|
Сделан специально для [Doggy Dox](https://www.twitch.tv/doggy_dox).
|
||||||
|
|
||||||
|
## Переменные
|
||||||
|
|
||||||
|
`DATABASE_URL=postgres://<username>:<password>@<address>/<database>`
|
||||||
|
|
||||||
|
Параметры базы данных
|
||||||
|
|
||||||
|
`TOKEN=<bot_token>`
|
||||||
|
|
||||||
|
Токен бота. Можно получить у [@botfather](tg://resolve?domain=botfather)
|
||||||
|
|
||||||
|
`ADMINISTRATORS=<user_id>[,<user_id>,...]`
|
||||||
|
|
||||||
|
ID администраторов, разделяется запятой.
|
||||||
|
Можно получить у [@getmyid_bot](tg://resolve?domain=getmyid_bot)
|
||||||
|
|
||||||
|
`CHANNEL=<chat_id>`
|
||||||
|
|
||||||
|
ID канала для проверки подписки.
|
||||||
|
Можно получить у [@getmyid_bot](tg://resolve?domain=getmyid_bot) переслав ему сообщения из канала.
|
||||||
|
|
||||||
|
`CHANNEL_INVITE_HASH=<hash>`
|
||||||
|
|
||||||
|
Хэш для инвайт ссылки (необязательно). Хэш можно извлечь из ссылки-приглашения после плюса.
|
||||||
|
Пример: `https://t.me/+<hash>`
|
||||||
|
|
||||||
|
`RUST_LOG=<level>[,target=level,...]`
|
||||||
|
|
||||||
|
Журналирование (необязательно).
|
||||||
|
Типы:
|
||||||
|
`trace, debug, info, warn, error`
|
||||||
|
Также можно указать отдельный уровень логирования для отдельных целей.
|
||||||
|
|
||||||
|
`TELEGRAM_API_URL=<url>`
|
||||||
|
|
||||||
|
Сторонний Telegram Bot API сервер (необязательно).
|
||||||
|
|
||||||
|
### Только для Docker
|
||||||
|
|
||||||
|
`TZ=<TZ_identifier>`
|
||||||
|
|
||||||
|
Необязательно, но рекомендуется, т.к. данные в БД хранятся без часового пояса.
|
||||||
|
Можно взять из [таблицы с Википедии](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||

|

|
||||||
в
|
в
|
||||||
[](https://t.me/shiroyashik)
|
[](https://t.me/shiroyashik)
|
||||||
или
|
или
|
||||||

|

|
||||||
|
|
||||||
Если у вас есть идем, нашли баг или хотите предложить улучшения
|
Если у вас есть идеи, нашли баг или хотите предложить улучшения:
|
||||||
создавайте [issue](https://github.com/shiroyashik/doggy-watch/issues)
|
создавайте [issue](https://github.com/shiroyashik/doggy-watch/issues)
|
||||||
или свяжитесь со мной напрямую через Discord/Telegram (**@shiroyashik**).
|
или свяжитесь со мной напрямую через Discord/Telegram (**@shiroyashik**).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,22 +108,22 @@ impl MigrationTrait for Migration {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
// Videos
|
// Actions
|
||||||
manager
|
manager
|
||||||
.drop_table(Table::drop().table(Videos::Table).to_owned())
|
.drop_table(Table::drop().table(Actions::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())
|
||||||
|
|
@ -10,10 +10,17 @@ 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.from.ok_or(anyhow::anyhow!("Message not from user!"))?.id).await {
|
if let Some(user) = check_subscription(&bot, &msg.clone().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 = get_video_metadata(&ytid).await?;
|
let meta = match 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>",
|
||||||
|
|
@ -21,6 +28,7 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,161 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use teloxide::{prelude::*, types::{LinkPreviewOptions, ParseMode}};
|
use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, 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();
|
|
||||||
if !videos.is_empty() {
|
let result = generate_list(videos, &state).await;
|
||||||
let mut by_date: IndexMap<Date, Vec<Video>> = IndexMap::new();
|
match result {
|
||||||
for (request, video) in videos {
|
Ok(list) => {
|
||||||
let video = video.unwrap();
|
let result = if let Some(list) = list {
|
||||||
let creator = if let Some(c) = request.find_related(actions::Entity).order_by(actions::Column::Id, Order::Asc).one(&state.db).await? {
|
list
|
||||||
c
|
|
||||||
} else {
|
} else {
|
||||||
let data = format!("Can't find creator for {request:?}");
|
"Нет видео для просмотра :(".to_string()
|
||||||
bot.send_message(msg.chat.id, data.clone()).await?;
|
|
||||||
anyhow::bail!(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let contributors = request.find_related(actions::Entity).count(&state.db).await?;
|
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
|
||||||
let date = creator.created_at.date();
|
vec![InlineKeyboardButton::callback("Непросмотренные", "list_unviewed")],
|
||||||
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?;
|
bot.send_message(msg.chat.id, result).parse_mode(ParseMode::Html)
|
||||||
let archived_times = archived::Entity::find().filter(archived::Column::Ytid.eq(video.ytid)).count(&state.db).await?;
|
.link_preview_options(LinkPreviewOptions {
|
||||||
|
is_disabled: true,
|
||||||
let mut status = String::new();
|
url: None,
|
||||||
status.push(if request.viewed_at.is_some() {
|
prefer_small_media: false,
|
||||||
'👀'
|
prefer_large_media: false,
|
||||||
} else if viewed_times != 0 {
|
show_above_text: false
|
||||||
'⭐'
|
}).reply_markup(InlineKeyboardMarkup::new(keyboard)).await?;
|
||||||
} else if archived_times != 0 {
|
},
|
||||||
'📁'
|
Err(e) => {
|
||||||
} else {
|
tracing::error!("{:?}", e);
|
||||||
'🆕'
|
bot.send_message(msg.chat.id, "Произошла ошибка!").await?;
|
||||||
});
|
},
|
||||||
|
|
||||||
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));
|
|
||||||
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn inline(state: Arc<AppState>, bot: Bot, q: CallbackQuery) -> anyhow::Result<()> {
|
||||||
|
bot.answer_callback_query(&q.id).await?;
|
||||||
|
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?;
|
||||||
|
let result = generate_list(videos, &state).await;
|
||||||
|
match result {
|
||||||
|
Ok(list) => {
|
||||||
|
let result = if let Some(list) = list {
|
||||||
|
list
|
||||||
|
} else {
|
||||||
|
"Нет видео для просмотра :(".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyboard: Vec<Vec<InlineKeyboardButton>> = vec![
|
||||||
|
vec![InlineKeyboardButton::callback("Обновить", "list_unviewed")],
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(message) = q.regular_message() {
|
||||||
|
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 {
|
||||||
|
bot.send_message(q.from.id, result).parse_mode(ParseMode::Html)
|
||||||
|
.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?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("{:?}", e);
|
||||||
|
bot.send_message(q.from.id, "Произошла ошибка!").await?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ 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))
|
||||||
|
|
@ -73,6 +74,7 @@ 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))
|
||||||
|
|
|
||||||
|
|
@ -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 i32).exec(&state.db).await?.rows_affected != 0 {
|
if Entity::delete_by_id(uid as i64).exec(&state.db).await?.rows_affected != 0 {
|
||||||
"Модератор удалён!"
|
"Модератор удалён!"
|
||||||
} else {
|
} else {
|
||||||
"Произошла ошибка!\nПо всей видимости такого модератора не существует."
|
"Произошла ошибка!\nПо всей видимости такого модератора не существует."
|
||||||
|
|
|
||||||
|
|
@ -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 i32).one(&state.db).await? {
|
let text = if let Some(moder) = moderators::Entity::find_by_id(uid.0 as i64).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();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub enum InlineCommand {
|
||||||
Unview(i32),
|
Unview(i32),
|
||||||
ArchiveViewed,
|
ArchiveViewed,
|
||||||
ArchiveAll,
|
ArchiveAll,
|
||||||
|
ListUnviewed,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
21
src/main.rs
21
src/main.rs
|
|
@ -2,6 +2,7 @@ use std::{env::var, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use database::moderators;
|
use database::moderators;
|
||||||
|
use migration::{Migrator, MigratorTrait};
|
||||||
use sea_orm::{prelude::*, sea_query::OnConflict, ConnectOptions, Database, Set};
|
use sea_orm::{prelude::*, sea_query::OnConflict, ConnectOptions, Database, Set};
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
dispatching::dialogue::InMemStorage,
|
dispatching::dialogue::InMemStorage,
|
||||||
|
|
@ -16,6 +17,7 @@ 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");
|
||||||
|
|
@ -27,6 +29,12 @@ 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.")
|
||||||
};
|
};
|
||||||
|
|
@ -59,13 +67,16 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tracing::info!("Doggy-Watch v{VERSION}");
|
tracing::info!("Doggy-Watch v{VERSION}");
|
||||||
tracing::info!("{:?}", *ADMINISTRATORS);
|
tracing::info!("admins: {:?} tg api: {}", *ADMINISTRATORS, TELEGRAM_API_URL.as_str());
|
||||||
let bot = Bot::new(&*TOKEN);
|
let bot = Bot::new(&*TOKEN).set_api_url(TELEGRAM_API_URL.clone());
|
||||||
|
|
||||||
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);
|
||||||
let db: DatabaseConnection = Database::connect(opt).await?;
|
let db: DatabaseConnection = Database::connect(opt).await?;
|
||||||
|
|
||||||
|
// applying migrations
|
||||||
|
Migrator::up(&db, None).await?;
|
||||||
|
|
||||||
// add administrators to db
|
// add administrators to db
|
||||||
{
|
{
|
||||||
let admins: Vec<moderators::ActiveModel> = ADMINISTRATORS.iter().map(|&x| {
|
let admins: Vec<moderators::ActiveModel> = ADMINISTRATORS.iter().map(|&x| {
|
||||||
|
|
@ -89,7 +100,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::warn!("Unhandled update: {:?}", upd);
|
tracing::debug!("Unhandled update: {:?}", upd);
|
||||||
})
|
})
|
||||||
.enable_ctrlc_handler()
|
.enable_ctrlc_handler()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -116,6 +127,8 @@ 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 = "действия с архивом.")]
|
||||||
|
|
@ -180,7 +193,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 i32).one(&self.db).await? {
|
Ok(if let Some(moder) = Moderators::find_by_id(uid.0 as i64).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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue