Reworked telegram notifications
Build and Publish / Build and Publish Docker Image (push) Successful in 1m50s

This commit is contained in:
2026-05-15 15:28:19 +03:00
parent 3bf62c80d5
commit 85512ab48b
10 changed files with 132 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "web-petting" name = "web-petting"
version = "0.1.7" version = "0.1.8"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+34 -2
View File
@@ -401,6 +401,8 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
login: form.login, login: form.login,
password_hash: password_auth::generate_hash(&form.password), password_hash: password_auth::generate_hash(&form.password),
display_name: display, display_name: display,
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(), status: "active".to_string(),
created_at: now_utc(), created_at: now_utc(),
updated_at: now_utc(), updated_at: now_utc(),
@@ -760,6 +762,8 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
login: form.login, login: form.login,
password_hash: password_auth::generate_hash(&form.password), password_hash: password_auth::generate_hash(&form.password),
display_name: display, display_name: display,
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(), status: "active".to_string(),
created_at: now_utc(), created_at: now_utc(),
updated_at: now_utc(), updated_at: now_utc(),
@@ -788,7 +792,6 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
#[derive(Deserialize)] #[derive(Deserialize)]
struct SettingsForm { struct SettingsForm {
telegram_bot_token: String, telegram_bot_token: String,
telegram_chat_id: String,
contact_info: String, contact_info: String,
pricing_info: String, pricing_info: String,
timezone: String, timezone: String,
@@ -804,7 +807,6 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
for (key, value) in [ for (key, value) in [
("telegram_bot_token", form.telegram_bot_token), ("telegram_bot_token", form.telegram_bot_token),
("telegram_chat_id", form.telegram_chat_id),
("contact_info", form.contact_info), ("contact_info", form.contact_info),
("pricing_info", form.pricing_info), ("pricing_info", form.pricing_info),
("timezone", form.timezone), ("timezone", form.timezone),
@@ -975,6 +977,31 @@ async fn user_activate(
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response() Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
} }
#[derive(Deserialize)]
struct UserTelegramForm {
telegram_chat_id: Option<String>,
telegram_notifications: Option<String>,
}
async fn user_update_telegram(
request: Request,
session: Session,
db: Database,
Path(user_id): Path<i64>,
) -> cot::Result<Response> {
let (lang, form): (_, UserTelegramForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.telegram_chat_id = form.telegram_chat_id.filter(|s| !s.trim().is_empty());
user.telegram_notifications = Some(form.telegram_notifications.as_deref() == Some("true"));
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct AddLeadForm { struct AddLeadForm {
name: String, name: String,
@@ -2077,6 +2104,11 @@ pub fn admin_router() -> Router {
user_activate, user_activate,
"admin-user-activate", "admin-user-activate",
), ),
Route::with_handler_and_name(
"/users/{user_id}/telegram",
user_update_telegram,
"admin-user-telegram",
),
Route::with_handler_and_name("/media", media_page, "admin-media"), Route::with_handler_and_name("/media", media_page, "admin-media"),
Route::with_handler_and_name( Route::with_handler_and_name(
"/media/{visit_id}/upload", "/media/{visit_id}/upload",
+6
View File
@@ -118,6 +118,8 @@ pub struct Translations {
pub users_add_button: &'static str, pub users_add_button: &'static str,
pub users_error_passwords_mismatch: &'static str, pub users_error_passwords_mismatch: &'static str,
pub users_error_login_taken: &'static str, pub users_error_login_taken: &'static str,
pub users_telegram_chat_id: &'static str,
pub users_telegram_enabled: &'static str,
// Settings // Settings
pub settings_title: &'static str, pub settings_title: &'static str,
@@ -329,6 +331,8 @@ static RU: Translations = Translations {
users_add_button: "Добавить", users_add_button: "Добавить",
users_error_passwords_mismatch: "Пароли не совпадают.", users_error_passwords_mismatch: "Пароли не совпадают.",
users_error_login_taken: "Этот логин уже занят.", users_error_login_taken: "Этот логин уже занят.",
users_telegram_chat_id: "Telegram Chat ID",
users_telegram_enabled: "Уведомления",
settings_title: "Настройки", settings_title: "Настройки",
settings_key: "Параметр", settings_key: "Параметр",
@@ -529,6 +533,8 @@ static EN: Translations = Translations {
users_add_button: "Add", users_add_button: "Add",
users_error_passwords_mismatch: "Passwords do not match.", users_error_passwords_mismatch: "Passwords do not match.",
users_error_login_taken: "This login is already taken.", users_error_login_taken: "This login is already taken.",
users_telegram_chat_id: "Telegram Chat ID",
users_telegram_enabled: "Notifications",
settings_title: "Settings", settings_title: "Settings",
settings_key: "Parameter", settings_key: "Parameter",
+2
View File
@@ -7,6 +7,7 @@ pub mod m_0002_visit_schedule;
pub mod m_0003_visit_feedback; pub mod m_0003_visit_feedback;
pub mod m_0004_visit_public_notes; pub mod m_0004_visit_public_notes;
pub mod m_0005_testimonials; pub mod m_0005_testimonials;
pub mod m_0006_user_telegram;
/// The list of migrations for current app. /// The list of migrations for current app.
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration, &m_0001_initial::Migration,
@@ -14,4 +15,5 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0003_visit_feedback::Migration, &m_0003_visit_feedback::Migration,
&m_0004_visit_public_notes::Migration, &m_0004_visit_public_notes::Migration,
&m_0005_testimonials::Migration, &m_0005_testimonials::Migration,
&m_0006_user_telegram::Migration,
]; ];
+35
View File
@@ -0,0 +1,35 @@
//! Migration: add telegram_chat_id and telegram_notifications to User
#[derive(Debug, Copy, Clone)]
pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0006_user_telegram";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
&[::cot::db::migrations::MigrationDependency::migration(
"web-petting",
"m_0005_testimonials",
)];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__user"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("telegram_chat_id"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
)
.build(),
::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__user"))
.field(
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("telegram_notifications"),
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
)
.build(),
];
}
+2
View File
@@ -179,6 +179,8 @@ pub struct User {
pub login: String, pub login: String,
pub password_hash: String, pub password_hash: String,
pub display_name: Option<String>, pub display_name: Option<String>,
pub telegram_chat_id: Option<String>,
pub telegram_notifications: Option<bool>,
/// active | archived /// active | archived
pub status: String, pub status: String,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
+28 -14
View File
@@ -1,8 +1,8 @@
use cot::db::{Database, query}; use cot::db::{Database, query};
use crate::models::Setting; use crate::models::{Setting, User};
/// Send a Telegram message using bot settings from DB. /// Send a Telegram notification to all admins with notifications enabled.
/// Silently ignores errors (missing config, network issues) — notifications are best-effort. /// Silently ignores errors (missing config, network issues) — notifications are best-effort.
pub async fn notify_new_lead( pub async fn notify_new_lead(
db: &Database, db: &Database,
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
Some(t) if !t.is_empty() => t, Some(t) if !t.is_empty() => t,
_ => return, _ => return,
}; };
let chat_id = match get_setting(db, "telegram_chat_id").await {
Some(c) if !c.is_empty() => c,
_ => return,
};
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}"); let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
if let Some(phone) = phone.filter(|s| !s.is_empty()) { if let Some(phone) = phone.filter(|s| !s.is_empty()) {
@@ -27,15 +23,33 @@ pub async fn notify_new_lead(
text.push_str(&format!("\nКомментарий: {comment}")); text.push_str(&format!("\nКомментарий: {comment}"));
} }
let active = "active".to_string();
let users = match query!(User, $status == active).all(db).await {
Ok(u) => u,
Err(_) => return,
};
let client = reqwest::Client::new();
let url = format!("https://api.telegram.org/bot{token}/sendMessage"); let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let _ = reqwest::Client::new()
.post(&url) for user in &users {
.json(&serde_json::json!({ if user.telegram_notifications != Some(true) {
"chat_id": chat_id, continue;
"text": text, }
})) let chat_id = match &user.telegram_chat_id {
.send() Some(id) if !id.is_empty() => id,
.await; _ => continue,
};
let _ = client
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"text": text,
}))
.send()
.await;
}
} }
async fn get_setting(db: &Database, key_name: &str) -> Option<String> { async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
-6
View File
@@ -20,12 +20,6 @@
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}"> <input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ t.settings_telegram_chat_id }}</label>
<div class="control">
<input class="input" type="text" name="telegram_chat_id" value="{% for s in &settings %}{% if s.key == "telegram_chat_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field"> <div class="field">
<label class="label">{{ t.settings_contact_info }}</label> <label class="label">{{ t.settings_contact_info }}</label>
<div class="control"> <div class="control">
+23
View File
@@ -17,6 +17,29 @@
<div class="item-card-meta"> <div class="item-card-meta">
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span> <span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
</div> </div>
{% if user.status == "active" %}
<form method="post" action="/admin/users/{{ user.id }}/telegram" style="margin-top:0.5rem; padding-top:0.5rem; border-top:1px solid #eee;">
<div class="columns is-mobile is-vcentered" style="margin-bottom:0;">
<div class="column">
<div class="field" style="margin-bottom:0;">
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
<div class="control">
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
</div>
</div>
</div>
<div class="column is-narrow">
<label class="checkbox is-small">
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
{{ t.users_telegram_enabled }}
</label>
</div>
<div class="column is-narrow">
<button type="submit" class="button is-small is-info is-outlined">💾</button>
</div>
</div>
</form>
{% endif %}
<div class="item-card-actions"> <div class="item-card-actions">
{% if user.status == "active" %} {% if user.status == "active" %}
<form method="post" action="/admin/users/{{ user.id }}/archive"> <form method="post" action="/admin/users/{{ user.id }}/archive">
+1 -1
View File
@@ -304,7 +304,7 @@
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 600px) { @media (max-width: 600px) {
.hero { padding: 6.5rem 1rem 3rem; } .hero { padding: 6.5rem 1rem 10rem; }
.section { padding: 3rem 1rem; } .section { padding: 3rem 1rem; }
.form-section { padding: 3rem 1rem; } .form-section { padding: 3rem 1rem; }
.form-wrapper { padding: 1.75rem 1.25rem; } .form-wrapper { padding: 1.75rem 1.25rem; }