Added telegram bot WEB App
Build and Publish / Build and Publish Docker Image (push) Successful in 6m0s

This commit is contained in:
Ultradesu
2026-07-01 12:44:53 +03:00
parent 3a4bc23a58
commit 77cde17ef9
10 changed files with 710 additions and 63 deletions
+3 -2
View File
@@ -839,8 +839,9 @@ fn telegram_setup_instructions(config: &AppConfig) -> String {
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\
BotFather:\n\
1. Create or reuse this bot and set the Web App URL to https://<APP_HOST>/configs.\n\
2. Make the bot reply with the sender numeric Telegram ID.\n\
3. Users must open the bot and send /start once before notifications can work."
2. Keep webhook disabled: this process receives bot messages with getUpdates polling.\n\
3. Users open the portal, copy the one-time secret code and send it to this bot.\n\
4. Users must open the bot and send /start once before notifications can work."
)
}
+71 -32
View File
@@ -151,6 +151,9 @@ struct TelegramLinkStatusResponse {
bot_url: String,
linked: bool,
declined: bool,
pending: bool,
pending_secret: Option<String>,
pending_expires_at: Option<i64>,
telegram_id: Option<String>,
}
@@ -164,11 +167,6 @@ struct TelegramWebAppLinkRequest {
init_data: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TelegramManualLinkRequest {
telegram_id: String,
}
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
async fn vpn_clients_handler(
@@ -294,10 +292,9 @@ async fn telegram_link_webapp_handler(
.into_response()
}
async fn telegram_link_manual_handler(
async fn telegram_link_start_handler(
session: Session,
db: Database,
Json(request): Json<TelegramManualLinkRequest>,
) -> cot::Result<cot::response::Response> {
let mut user = match api_user_record(&session, &db).await? {
Ok(user) => user,
@@ -314,12 +311,15 @@ async fn telegram_link_manual_handler(
));
}
let telegram_id = match normalize_manual_telegram_id(&request.telegram_id) {
Ok(value) => value,
Err(response) => return Ok(response),
};
if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? {
return Ok(response);
let code = create_unique_telegram_link_code(&db).await?;
let now = telegram::now_unix_seconds();
user.set_telegram_link_code(&db, Some(&code), Some(now))
.await
.map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?;
if user.telegram_id() == Some("") {
user.set_telegram_id(&db, None).await.map_err(|e| {
cot::Error::internal(format!("failed to reset Telegram preference: {e}"))
})?;
}
Json(TelegramLinkResponse {
@@ -347,7 +347,7 @@ async fn telegram_link_decline_handler(
));
}
user.set_telegram_id(&db, Some(""))
user.decline_telegram_link(&db)
.await
.map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?;
@@ -357,6 +357,26 @@ async fn telegram_link_decline_handler(
.into_response()
}
async fn telegram_link_unlink_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
let mut user = match api_user_record(&session, &db).await? {
Ok(user) => user,
Err(response) => return Ok(response),
};
let (config, _) = AppConfig::load_with_db(&db).await;
user.clear_telegram_link(&db)
.await
.map_err(|e| cot::Error::internal(format!("failed to clear Telegram link: {e}")))?;
Json(TelegramLinkResponse {
status: telegram_status_response(&config, &user),
})
.into_response()
}
async fn create_vpn_client_handler(
session: Session,
db: Database,
@@ -626,6 +646,17 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat
.as_deref()
.is_some_and(|value| !value.is_empty());
let declined = telegram_id.as_deref() == Some("");
let pending = user.telegram_link_code().is_some()
&& telegram::link_code_is_active(user.telegram_link_code_created_at());
let pending_secret = pending
.then(|| user.telegram_link_code().map(str::to_owned))
.flatten();
let pending_expires_at = pending
.then(|| {
user.telegram_link_code_created_at()
.map(telegram::link_code_expires_at)
})
.flatten();
TelegramLinkStatusResponse {
enabled: config.telegram_bot_enabled,
@@ -633,6 +664,9 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat
bot_username,
linked,
declined,
pending,
pending_secret,
pending_expires_at,
telegram_id: linked.then_some(telegram_id).flatten(),
}
}
@@ -649,21 +683,21 @@ fn telegram_bot_url(username: &str) -> String {
}
}
fn normalize_manual_telegram_id(telegram_id: &str) -> Result<String, cot::response::Response> {
let telegram_id = telegram_id.trim();
if telegram_id.is_empty()
|| telegram_id.len() > 32
|| !telegram_id.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(json_error_typed(
cot::http::StatusCode::BAD_REQUEST,
"telegram_id_invalid",
"Telegram ID is invalid",
"Paste the numeric Telegram ID returned by the bot.",
"",
));
async fn create_unique_telegram_link_code(db: &Database) -> cot::Result<String> {
for _ in 0..8 {
let code = telegram::generate_link_code().map_err(cot::Error::internal)?;
let existing = User::get_by_telegram_link_code(db, &code)
.await
.map_err(|e| {
cot::Error::internal(format!("failed to check Telegram link code: {e}"))
})?;
if existing.is_none() {
return Ok(code);
}
}
Ok(telegram_id.to_owned())
Err(cot::Error::internal(
"failed to generate unique Telegram link code",
))
}
async fn link_telegram_id(
@@ -686,7 +720,7 @@ async fn link_telegram_id(
}
}
user.set_telegram_id(db, Some(telegram_id))
user.complete_telegram_link(db, telegram_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?;
Ok(None)
@@ -798,15 +832,20 @@ impl App for ApiApp {
"api_telegram_link_webapp",
),
Route::with_api_handler_and_name(
"/telegram-link/manual",
api_post(telegram_link_manual_handler),
"api_telegram_link_manual",
"/telegram-link/start",
api_post(telegram_link_start_handler),
"api_telegram_link_start",
),
Route::with_api_handler_and_name(
"/telegram-link/decline",
api_post(telegram_link_decline_handler),
"api_telegram_link_decline",
),
Route::with_api_handler_and_name(
"/telegram-link/unlink",
api_post(telegram_link_unlink_handler),
"api_telegram_link_unlink",
),
Route::with_api_handler_and_name(
"/vpn-clients/sync",
api_post(sync_vpn_clients_handler),
+20 -7
View File
@@ -162,20 +162,33 @@ translations! {
notice_detail: "Detail" , "Детали";
telegram_link_title: "Connect Telegram" , "Подключить Telegram";
telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота.";
telegram_manual_message: "You can connect Telegram once and manage VPN keys from the bot." , "Можно один раз подключить Telegram и управлять VPN-ключами через бота.";
telegram_manual_message: "Connect Telegram by sending a one-time secret code to the bot." , "Подключите Telegram, отправив одноразовый секретный код боту.";
telegram_connect: "Connect" , "Подключить";
telegram_skip: "Do not ask again" , "Больше не предлагать";
telegram_guide_title: "How to get your Telegram ID" , "Как узнать Telegram ID";
telegram_guide_title: "Send this code to the bot" , "Отправьте этот код боту";
telegram_open_bot: "Open bot" , "Открыть бота";
telegram_guide_start: "Open the bot and send /start once." , "Откройте бота и один раз отправьте /start.";
telegram_guide_get_id: "Ask the bot for your ID; it will reply with only the number." , "Напишите боту, он ответит только номером вашего ID.";
telegram_guide_paste: "Paste that number here and save it." , "Вставьте этот номер сюда и сохраните.";
telegram_id_label: "Telegram ID" , "Telegram ID";
telegram_id_placeholder: "Only digits" , "Только цифры";
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус.";
telegram_secret_label: "Secret code" , "Секретный код";
telegram_save: "Save Telegram" , "Сохранить Telegram";
telegram_saved: "Telegram connected." , "Telegram подключён.";
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
telegram_status_connected: "Telegram connected" , "Telegram подключён";
telegram_status_pending: "Telegram code is waiting" , "Код Telegram ожидает";
telegram_status_empty: "Telegram is not connected" , "Telegram не подключён";
telegram_status_disabled: "Telegram is disabled" , "Telegram отключён";
telegram_change: "Change" , "Сменить";
telegram_delete: "Remove" , "Удалить";
telegram_delete_confirm: "Remove Telegram from this account?" , "Удалить Telegram из этого аккаунта?";
telegram_unlinked: "Telegram removed." , "Telegram удалён.";
telegram_copy_secret: "Copy code" , "Копировать код";
telegram_secret_copied: "Secret code copied." , "Секретный код скопирован.";
telegram_refresh_status: "Check status" , "Проверить статус";
telegram_pending_message: "Waiting for a message with the secret code." , "Жду сообщение с секретным кодом.";
telegram_connected_message: "This account is ready for Telegram bot actions." , "Этот аккаунт готов к действиям через Telegram-бота.";
telegram_not_connected_message: "Telegram is not connected yet." , "Telegram пока не подключён.";
// VPN server management
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
+1
View File
@@ -412,6 +412,7 @@ fn main() -> impl Project {
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing::info!("loaded config: {:?}", app_config);
telegram::spawn_bot_worker(Arc::clone(&app_config));
AmneziaFellowProject { app_config }
}
+367 -1
View File
@@ -1,11 +1,20 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use cot::db::Database;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use url::form_urlencoded;
use crate::config::AppConfig;
use crate::user::User;
type HmacSha256 = Hmac<Sha256>;
const BOT_POLL_TIMEOUT_SECONDS: u64 = 25;
const BOT_IDLE_SLEEP: Duration = Duration::from_secs(15);
const BOT_ERROR_SLEEP: Duration = Duration::from_secs(5);
pub const TELEGRAM_LINK_CODE_TTL: Duration = Duration::from_secs(15 * 60);
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct TelegramWebAppUser {
@@ -49,6 +58,363 @@ impl std::fmt::Display for TelegramAuthError {
impl std::error::Error for TelegramAuthError {}
#[derive(Debug, Deserialize)]
struct TelegramApiResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
error_code: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
update_id: i64,
message: Option<TelegramMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramMessage {
chat: TelegramChat,
from: Option<TelegramBotUser>,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramChat {
id: i64,
}
#[derive(Debug, Deserialize)]
struct TelegramBotUser {
id: i64,
is_bot: bool,
}
#[derive(Debug, Serialize)]
struct GetUpdatesRequest {
offset: Option<i64>,
limit: u8,
timeout: u64,
allowed_updates: [&'static str; 1],
}
#[derive(Debug, Serialize)]
struct DeleteWebhookRequest {
drop_pending_updates: bool,
}
#[derive(Debug, Serialize)]
struct SendMessageRequest<'a> {
chat_id: i64,
text: &'a str,
disable_web_page_preview: bool,
}
pub fn spawn_bot_worker(config: Arc<AppConfig>) {
if config.database_url.trim().is_empty() {
tracing::warn!("Telegram bot worker disabled: database URL is empty");
return;
}
let database_url = config.database_url.clone();
tokio::spawn(async move {
run_bot_worker(database_url).await;
});
}
pub fn generate_link_code() -> Result<String, String> {
let mut bytes = [0_u8; 12];
getrandom::fill(&mut bytes)
.map_err(|e| format!("failed to generate Telegram link code: {e}"))?;
Ok(format!("af-{}", hex::encode(bytes)))
}
pub fn now_unix_seconds() -> i64 {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
i64::try_from(seconds).unwrap_or(i64::MAX)
}
pub fn link_code_expires_at(created_at: i64) -> i64 {
let ttl = i64::try_from(TELEGRAM_LINK_CODE_TTL.as_secs()).unwrap_or(i64::MAX);
created_at.saturating_add(ttl)
}
pub fn link_code_is_active(created_at: Option<i64>) -> bool {
created_at
.map(|created_at| now_unix_seconds() <= link_code_expires_at(created_at))
.unwrap_or(false)
}
fn normalize_link_code(text: &str) -> Option<String> {
text.split_whitespace().find_map(|part| {
let candidate = part.trim_matches(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'));
let candidate = candidate.to_ascii_lowercase();
let suffix = candidate.strip_prefix("af-")?;
(suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(candidate)
})
}
async fn run_bot_worker(database_url: String) {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(BOT_POLL_TIMEOUT_SECONDS + 10))
.build()
.expect("valid reqwest client");
let mut offset = None;
let mut active_token = String::new();
let mut webhook_deleted = false;
loop {
let db = match Database::new(database_url.clone()).await {
Ok(db) => db,
Err(e) => {
tracing::warn!(error = %e, "Telegram bot worker could not open database");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
continue;
}
};
loop {
let (config, _) = AppConfig::load_with_db(&db).await;
let token = config.telegram_bot_token.trim().to_owned();
if !config.telegram_bot_enabled || token.is_empty() {
offset = None;
active_token.clear();
webhook_deleted = false;
tokio::time::sleep(BOT_IDLE_SLEEP).await;
continue;
}
if active_token != token {
active_token = token.clone();
offset = None;
webhook_deleted = false;
}
if !webhook_deleted {
match delete_webhook(&http, &token).await {
Ok(()) => webhook_deleted = true,
Err(e) => {
tracing::warn!(error = %e, "Telegram bot worker could not delete webhook");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
continue;
}
}
}
match get_updates(&http, &token, offset).await {
Ok(updates) => {
for update in updates {
offset = Some(update.update_id.saturating_add(1));
if let Err(e) = process_update(&db, &http, &token, update).await {
tracing::warn!(error = %e, "Telegram bot update was not processed");
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Telegram bot polling failed");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
}
}
}
}
}
async fn delete_webhook(http: &reqwest::Client, token: &str) -> Result<(), String> {
let url = telegram_api_url(token, "deleteWebhook");
let response = http
.post(url)
.json(&DeleteWebhookRequest {
drop_pending_updates: false,
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<bool>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response).map(|_| ())
}
async fn get_updates(
http: &reqwest::Client,
token: &str,
offset: Option<i64>,
) -> Result<Vec<TelegramUpdate>, String> {
let url = telegram_api_url(token, "getUpdates");
let response = http
.post(url)
.json(&GetUpdatesRequest {
offset,
limit: 50,
timeout: BOT_POLL_TIMEOUT_SECONDS,
allowed_updates: ["message"],
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<Vec<TelegramUpdate>>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response)
}
async fn send_message(
http: &reqwest::Client,
token: &str,
chat_id: i64,
text: &str,
) -> Result<(), String> {
let url = telegram_api_url(token, "sendMessage");
let response = http
.post(url)
.json(&SendMessageRequest {
chat_id,
text,
disable_web_page_preview: true,
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<serde_json::Value>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response).map(|_| ())
}
async fn process_update(
db: &Database,
http: &reqwest::Client,
token: &str,
update: TelegramUpdate,
) -> Result<(), String> {
let Some(message) = update.message else {
return Ok(());
};
let Some(from) = message.from else {
return Ok(());
};
if from.is_bot {
return Ok(());
}
let Some(text) = message.text.as_deref() else {
return Ok(());
};
if text.trim().starts_with("/start") {
send_message(
http,
token,
message.chat.id,
"Open the VPN portal, press the Telegram button and send me the secret code shown there.",
)
.await?;
return Ok(());
}
let Some(code) = normalize_link_code(text) else {
send_message(
http,
token,
message.chat.id,
"I need the secret code from the VPN portal to connect your Telegram account.",
)
.await?;
return Ok(());
};
let Some(mut user) = User::get_by_telegram_link_code(db, &code)
.await
.map_err(|e| format!("failed to load Telegram link code: {e}"))?
else {
send_message(
http,
token,
message.chat.id,
"This code is unknown or already used. Generate a new code in the VPN portal.",
)
.await?;
return Ok(());
};
if !link_code_is_active(user.telegram_link_code_created_at()) {
user.set_telegram_link_code(db, None, None)
.await
.map_err(|e| format!("failed to clear expired Telegram link code: {e}"))?;
send_message(
http,
token,
message.chat.id,
"This code has expired. Generate a new code in the VPN portal.",
)
.await?;
return Ok(());
}
let telegram_id = from.id.to_string();
if let Some(existing) = User::get_by_telegram_id(db, &telegram_id)
.await
.map_err(|e| format!("failed to check Telegram ID: {e}"))?
{
if existing.id_val() != user.id_val() {
send_message(
http,
token,
message.chat.id,
"This Telegram account is already connected to another VPN account.",
)
.await?;
return Ok(());
}
}
user.complete_telegram_link(db, &telegram_id)
.await
.map_err(|e| format!("failed to save Telegram ID: {e}"))?;
send_message(
http,
token,
message.chat.id,
"Telegram connected. You can return to the VPN portal.",
)
.await?;
Ok(())
}
fn telegram_api_url(token: &str, method: &str) -> String {
format!("https://api.telegram.org/bot{token}/{method}")
}
fn telegram_api_result<T>(response: TelegramApiResponse<T>) -> Result<T, String> {
if response.ok {
response
.result
.ok_or_else(|| "Telegram API response did not include result".to_owned())
} else {
Err(match (response.error_code, response.description) {
(Some(code), Some(description)) => format!("Telegram API {code}: {description}"),
(_, Some(description)) => description,
(Some(code), None) => format!("Telegram API error {code}"),
(None, None) => "Telegram API error".to_owned(),
})
}
}
fn reqwest_error_message(error: reqwest::Error) -> String {
if error.is_timeout() {
"request timed out".to_owned()
} else if error.is_connect() {
"connection failed".to_owned()
} else if let Some(status) = error.status() {
format!("HTTP {status}")
} else {
"request failed".to_owned()
}
}
pub fn validate_web_app_init_data(
init_data: &str,
bot_token: &str,
+110
View File
@@ -18,6 +18,8 @@ pub struct User {
display_name: Option<String>,
avatar_url: Option<String>,
telegram_id: Option<String>,
telegram_link_code: Option<String>,
telegram_link_code_created_at: Option<i64>,
role: LimitedString<32>,
is_active: bool,
}
@@ -55,6 +57,8 @@ impl User {
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
telegram_link_code: None,
telegram_link_code_created_at: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -78,6 +82,8 @@ impl User {
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
telegram_link_code: None,
telegram_link_code_created_at: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -135,6 +141,21 @@ impl User {
.await
}
/// Find a user waiting for this Telegram link code.
pub async fn get_by_telegram_link_code(
db: &Database,
code: &str,
) -> cot::db::Result<Option<Self>> {
let code = code.trim();
if code.is_empty() {
return Ok(None);
}
let code = code.to_owned();
cot::db::query!(User, $telegram_link_code == Some(code))
.get(db)
.await
}
/// Count all users in the database.
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
Self::objects().count(db).await
@@ -168,6 +189,46 @@ impl User {
self.save(db).await
}
/// Store or clear a pending Telegram link code.
pub async fn set_telegram_link_code(
&mut self,
db: &Database,
code: Option<&str>,
created_at: Option<i64>,
) -> cot::db::Result<()> {
self.telegram_link_code = code.map(str::to_owned);
self.telegram_link_code_created_at = code.and(created_at);
self.save(db).await
}
/// Complete Telegram linking and clear any pending code.
pub async fn complete_telegram_link(
&mut self,
db: &Database,
telegram_id: &str,
) -> cot::db::Result<()> {
self.telegram_id = Some(telegram_id.to_owned());
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
self.save(db).await
}
/// Remove Telegram credentials and pending link data.
pub async fn clear_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
self.telegram_id = None;
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
self.save(db).await
}
/// Store an explicit opt-out and clear pending link data.
pub async fn decline_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
self.telegram_id = Some(String::new());
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
self.save(db).await
}
/// Delete this user by primary key.
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
cot::db::query!(User, $id == Auto::Fixed(user_id))
@@ -197,6 +258,14 @@ impl User {
self.telegram_id.as_deref()
}
pub fn telegram_link_code(&self) -> Option<&str> {
self.telegram_link_code.as_deref()
}
pub fn telegram_link_code_created_at(&self) -> Option<i64> {
self.telegram_link_code_created_at
}
pub fn role_str(&self) -> &str {
&self.role
}
@@ -454,10 +523,51 @@ pub mod db_migrations {
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()];
}
// -- M0008: pending Telegram link code on amnezia_fellow__user ---------
#[cot::db::migrations::migration_op]
async fn add_user_telegram_link_code(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_link_code TEXT")
.await?;
ctx.db
.raw(
"ALTER TABLE amnezia_fellow__user \
ADD COLUMN telegram_link_code_created_at INTEGER",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_link_code \
ON amnezia_fellow__user (telegram_link_code) \
WHERE telegram_link_code IS NOT NULL AND telegram_link_code != ''",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0008UserTelegramLinkCode;
impl migrations::Migration for M0008UserTelegramLinkCode {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0008_user_telegram_link_code";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0007_user_telegram_id",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_user_telegram_link_code).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0002CreateUser,
&M0003CreateOidcLink,
&M0004OidcLinkIndexes,
&M0007UserTelegramId,
&M0008UserTelegramLinkCode,
];
}