Skip to content

Commit c68a792

Browse files
committed
Replace noisy Telegram alerts with daily digest and moderation
summaries. Stop per-torrent annotation and personal scrape notifications, and send a daily digest with new stream/media counts by type plus contribution and moderation stats.
1 parent 76a91b4 commit c68a792

11 files changed

Lines changed: 604 additions & 230 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM cron_jobs WHERE name = 'daily_digest';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
INSERT INTO cron_jobs (name, schedule, queue, payload, enabled) VALUES
2+
('daily_digest', '0 8 * * *', 'daily_digest', '{}', true)
3+
ON CONFLICT (name) DO NOTHING;

backend/src/bot/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ mod model;
2020
mod notifications;
2121
mod session_setup;
2222
mod state_store;
23+
pub mod telegram_moderation;
2324
mod text;
2425
mod wizard;
2526

backend/src/bot/notifications.rs

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,14 @@
22
33
use std::sync::Arc;
44

5-
use crate::{state::AppState, util::notification_registry};
5+
use crate::state::AppState;
66

77
/// Wire Telegram bot notifications into the shared notification registry.
8-
pub fn register_notification_handlers(state: Arc<AppState>) {
9-
let bot_token = match state.config.telegram_bot_token.clone() {
10-
Some(t) if !t.is_empty() => t,
11-
_ => return,
12-
};
13-
let chat_id = match state.config.telegram_chat_id.clone() {
14-
Some(c) if !c.is_empty() => c,
15-
_ => return,
16-
};
17-
18-
let host_url = state.config.host_url.clone();
19-
let http = state.http.clone();
20-
21-
notification_registry::register_file_annotation_handler(Arc::new(
22-
move |info_hash, torrent_name| {
23-
let bot_token = bot_token.clone();
24-
let chat_id = chat_id.clone();
25-
let host_url = host_url.clone();
26-
let http = http.clone();
27-
Box::pin(async move {
28-
send_file_annotation_telegram(
29-
&http,
30-
&bot_token,
31-
&chat_id,
32-
&host_url,
33-
&info_hash,
34-
&torrent_name,
35-
)
36-
.await;
37-
})
38-
},
39-
));
40-
}
8+
///
9+
/// Per-torrent episode annotation alerts are intentionally not registered here;
10+
/// annotation queue counts are included in the daily digest and pending moderation
11+
/// reminder jobs instead.
12+
pub fn register_notification_handlers(_state: Arc<AppState>) {}
4113

4214
pub async fn send_block_notification(
4315
http: &reqwest::Client,
@@ -186,45 +158,11 @@ pub async fn send_content_received_notification(
186158
if let Some(err) = error_message.filter(|s| !s.is_empty()) {
187159
message.push_str(&format!("*Error*: `{err}`\n"));
188160
}
189-
send_text_message(http, bot_token, chat_id, &message).await;
190-
}
191-
192-
async fn send_file_annotation_telegram(
193-
http: &reqwest::Client,
194-
bot_token: &str,
195-
chat_id: &str,
196-
host_url: &str,
197-
info_hash: &str,
198-
torrent_name: &str,
199-
) {
200-
let annotation_url = format!(
201-
"{}/app/dashboard/moderator?tab=annotation",
202-
host_url.trim_end_matches('/')
203-
);
204-
let message = format!(
205-
"📝 Episode file mapping required\n\n\
206-
*Info Hash*: `{info_hash}`\n\
207-
*Torrent Name*: `{torrent_name}`\n\
208-
*Annotation Queue*: [Open]({annotation_url})\n\
209-
Please review and annotate the episode mappings manually."
210-
);
211-
send_text_message(http, bot_token, chat_id, &message).await;
161+
crate::bot::telegram_moderation::send_telegram_text(http, bot_token, chat_id, &message).await;
212162
}
213163

214164
async fn send_text_message(http: &reqwest::Client, bot_token: &str, chat_id: &str, message: &str) {
215-
let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
216-
let payload = serde_json::json!({
217-
"chat_id": chat_id,
218-
"text": message,
219-
"parse_mode": "Markdown",
220-
"disable_web_page_preview": true,
221-
});
222-
if let Err(e) = http.post(&url).json(&payload).send().await {
223-
tracing::warn!(
224-
error_kind = crate::util::http::transport_error_kind(&e),
225-
"telegram notification sendMessage failed: {e}"
226-
);
227-
}
165+
crate::bot::telegram_moderation::send_telegram_text(http, bot_token, chat_id, message).await;
228166
}
229167

230168
async fn send_photo_message(
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Shared Telegram moderation summaries and message helpers.
2+
3+
use chrono::{DateTime, Utc};
4+
use tracing::warn;
5+
6+
pub struct QueueCount {
7+
pub label: &'static str,
8+
pub count: i64,
9+
pub oldest: Option<DateTime<Utc>>,
10+
}
11+
12+
pub async fn send_telegram_text(
13+
http: &reqwest::Client,
14+
bot_token: &str,
15+
chat_id: &str,
16+
text: &str,
17+
) {
18+
let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
19+
let payload = serde_json::json!({
20+
"chat_id": chat_id,
21+
"text": text,
22+
"parse_mode": "Markdown",
23+
"disable_web_page_preview": true,
24+
});
25+
if let Err(e) = http.post(&url).json(&payload).send().await {
26+
warn!("telegram sendMessage failed: {e}");
27+
}
28+
}
29+
30+
pub fn format_pending_age(oldest: Option<DateTime<Utc>>) -> String {
31+
let Some(oldest) = oldest else {
32+
return "unknown".to_string();
33+
};
34+
let delta = Utc::now().signed_duration_since(oldest);
35+
let minutes = delta.num_minutes().max(0);
36+
let hours = minutes / 60;
37+
let days = hours / 24;
38+
if days > 0 {
39+
format!("{days}d {}h", hours % 24)
40+
} else if hours > 0 {
41+
format!("{hours}h {}m", minutes % 60)
42+
} else {
43+
format!("{minutes}m")
44+
}
45+
}
46+
47+
pub async fn collect_pending_counts(pool: &sqlx::PgPool) -> Result<Vec<QueueCount>, sqlx::Error> {
48+
let contribution = pending_count(
49+
pool,
50+
"SELECT COUNT(*), MIN(created_at) FROM contributions WHERE status = 'PENDING'",
51+
)
52+
.await?;
53+
let metadata = pending_count(
54+
pool,
55+
"SELECT COUNT(*), MIN(created_at) FROM metadata_suggestions WHERE status = 'pending'",
56+
)
57+
.await?;
58+
let stream = pending_count(
59+
pool,
60+
"SELECT COUNT(*), MIN(created_at) FROM stream_suggestions WHERE status = 'PENDING'",
61+
)
62+
.await?;
63+
let episode = pending_count(
64+
pool,
65+
"SELECT COUNT(*), MIN(created_at) FROM episode_suggestions WHERE status = 'pending'",
66+
)
67+
.await?;
68+
let annotation = pending_count(
69+
pool,
70+
r#"
71+
WITH unlinked_streams AS (
72+
SELECT DISTINCT sf.stream_id
73+
FROM stream_file sf
74+
INNER JOIN stream s ON s.id = sf.stream_id
75+
LEFT JOIN file_media_link fml_any ON fml_any.file_id = sf.id
76+
WHERE s.is_active = true
77+
AND s.is_blocked = false
78+
AND fml_any.id IS NULL
79+
),
80+
null_episode_pairs AS (
81+
SELECT DISTINCT sf.stream_id, fml_series.media_id
82+
FROM stream_file sf
83+
INNER JOIN stream s ON s.id = sf.stream_id
84+
INNER JOIN file_media_link fml_series ON fml_series.file_id = sf.id
85+
INNER JOIN stream_media_link sml
86+
ON sml.stream_id = sf.stream_id
87+
AND sml.media_id = fml_series.media_id
88+
INNER JOIN media m ON m.id = fml_series.media_id
89+
WHERE s.is_active = true
90+
AND s.is_blocked = false
91+
AND m.type = 'SERIES'
92+
AND fml_series.episode_number IS NULL
93+
),
94+
unmapped_pairs AS (
95+
SELECT DISTINCT us.stream_id, m.id AS media_id
96+
FROM unlinked_streams us
97+
INNER JOIN stream_media_link sml ON sml.stream_id = us.stream_id
98+
INNER JOIN media m ON sml.media_id = m.id
99+
WHERE m.type = 'SERIES'
100+
UNION
101+
SELECT nep.stream_id, nep.media_id
102+
FROM null_episode_pairs nep
103+
),
104+
annotated_streams AS (
105+
SELECT DISTINCT s.id AS stream_id, s.created_at
106+
FROM unmapped_pairs up
107+
INNER JOIN stream s ON s.id = up.stream_id
108+
)
109+
SELECT COUNT(*), MIN(created_at) FROM annotated_streams
110+
"#,
111+
)
112+
.await?;
113+
114+
Ok(vec![
115+
QueueCount {
116+
label: "Content Imports",
117+
count: contribution.0,
118+
oldest: contribution.1,
119+
},
120+
QueueCount {
121+
label: "Metadata Suggestions",
122+
count: metadata.0,
123+
oldest: metadata.1,
124+
},
125+
QueueCount {
126+
label: "Stream Suggestions",
127+
count: stream.0,
128+
oldest: stream.1,
129+
},
130+
QueueCount {
131+
label: "Episode Suggestions",
132+
count: episode.0,
133+
oldest: episode.1,
134+
},
135+
QueueCount {
136+
label: "File Annotation Requests",
137+
count: annotation.0,
138+
oldest: annotation.1,
139+
},
140+
])
141+
}
142+
143+
pub fn format_pending_queues_section(queues: &[QueueCount]) -> Vec<String> {
144+
let total_pending: i64 = queues.iter().map(|q| q.count).sum();
145+
if total_pending == 0 {
146+
return Vec::new();
147+
}
148+
149+
let mut lines = vec![
150+
"*Pending Moderation*".to_string(),
151+
format!("*Total Pending*: `{total_pending}`"),
152+
String::new(),
153+
];
154+
for item in queues {
155+
if item.count <= 0 {
156+
continue;
157+
}
158+
let oldest_age = format_pending_age(item.oldest);
159+
lines.push(format!(
160+
"- *{}*: `{}` (oldest `{oldest_age}`)",
161+
item.label, item.count
162+
));
163+
}
164+
lines
165+
}
166+
167+
async fn pending_count(
168+
pool: &sqlx::PgPool,
169+
sql: &str,
170+
) -> Result<(i64, Option<DateTime<Utc>>), sqlx::Error> {
171+
use sqlx::Row;
172+
let row = sqlx::query(sqlx::AssertSqlSafe(sql))
173+
.fetch_one(pool)
174+
.await?;
175+
let count: i64 = row.try_get(0)?;
176+
let oldest: Option<DateTime<Utc>> = row.try_get(1)?;
177+
Ok((count, oldest))
178+
}

0 commit comments

Comments
 (0)