Skip to content

Commit 118b184

Browse files
committed
Use bot MTProto and Bot API for Telegram backup operations.
Backup restore runs via the bot token without a user scraping session; backup store tries bot copyMessage first and falls back to user MTProto when needed.
1 parent 0aa2774 commit 118b184

4 files changed

Lines changed: 245 additions & 41 deletions

File tree

backend/src/bot/api.rs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,43 @@ impl BotApi {
193193
from_chat_id: i64,
194194
message_id: i64,
195195
) -> Result<Value, BotApiError> {
196-
let data = self
197-
.post(
198-
"copyMessage",
199-
json!({
200-
"chat_id": chat_id,
201-
"from_chat_id": from_chat_id,
202-
"message_id": message_id,
203-
}),
204-
)
205-
.await?;
196+
self.copy_message_with_caption(chat_id, from_chat_id, message_id, None)
197+
.await
198+
}
199+
200+
pub async fn copy_message_with_caption(
201+
&self,
202+
chat_id: i64,
203+
from_chat_id: i64,
204+
message_id: i64,
205+
caption: Option<&str>,
206+
) -> Result<Value, BotApiError> {
207+
self.copy_message_with_caption_json(
208+
json!(chat_id),
209+
json!(from_chat_id),
210+
message_id,
211+
caption,
212+
)
213+
.await
214+
}
215+
216+
pub async fn copy_message_with_caption_json(
217+
&self,
218+
chat_id: Value,
219+
from_chat_id: Value,
220+
message_id: i64,
221+
caption: Option<&str>,
222+
) -> Result<Value, BotApiError> {
223+
let mut body = json!({
224+
"chat_id": chat_id,
225+
"from_chat_id": from_chat_id,
226+
"message_id": message_id,
227+
});
228+
if let Some(text) = caption {
229+
body["caption"] = json!(text);
230+
body["parse_mode"] = json!("Markdown");
231+
}
232+
let data = self.post("copyMessage", body).await?;
206233
data.get("result")
207234
.cloned()
208235
.ok_or_else(|| BotApiError::Parse("missing result".into()))

backend/src/jobs/handlers/telegram_backup_ops.rs

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
//! Admin jobs: copy Telegram streams to the backup channel or restore DB rows from it.
22
33
use async_trait::async_trait;
4+
use std::collections::HashMap;
5+
use std::sync::Arc;
46
use tracing::info;
57

8+
use grammers_client::Client;
9+
use grammers_session::types::PeerRef;
10+
611
use crate::{
712
db::{
813
telegram::{TelegramStreamBackupRow, list_streams_for_backup_store},
@@ -12,9 +17,10 @@ use crate::{
1217
error::JobError,
1318
handler::{JobCtx, JobHandler},
1419
},
20+
scrapers::telegram_clients::is_auth_key_duplicated,
1521
services::telegram_backup::{
16-
BackupBatchMetrics, resolve_mtproto_client, restore_stream_from_backup_message,
17-
store_stream_to_backup,
22+
BackupBatchMetrics, resolve_bot_mtproto_client, resolve_mtproto_client,
23+
restore_stream_from_backup_message, store_stream_to_backup,
1824
},
1925
services::telegram_peer,
2026
};
@@ -71,10 +77,42 @@ fn default_capture_file_id() -> bool {
7177
true
7278
}
7379

80+
async fn load_user_session(
81+
ctx: &JobCtx,
82+
preferred: Option<UserId>,
83+
) -> (Option<Arc<Client>>, Option<HashMap<i64, PeerRef>>) {
84+
for attempt in 0..2 {
85+
let session = resolve_mtproto_client(&ctx.state, preferred).await.ok();
86+
let Some((user_id, client)) = session else {
87+
return (None, None);
88+
};
89+
90+
let (dialog_peers, dialog_error) =
91+
telegram_peer::load_dialog_peer_map(client.as_ref()).await;
92+
if let Some(err) = dialog_error.as_deref()
93+
&& attempt == 0
94+
&& is_auth_key_duplicated(err)
95+
{
96+
tracing::warn!(
97+
"telegram_backup_store: AUTH_KEY_DUPLICATED for user {} — recycling client",
98+
user_id.0
99+
);
100+
ctx.state.telegram_clients.invalidate(user_id).await;
101+
telegram_peer::invalidate_dialog_peer_cache(user_id).await;
102+
continue;
103+
}
104+
105+
return (Some(client), Some(dialog_peers));
106+
}
107+
108+
(None, None)
109+
}
110+
74111
async fn run_backup_store_batch(
75112
ctx: &JobCtx,
76113
args: &TelegramBackupStoreArgs,
77-
client: &grammers_client::Client,
114+
user_client: Option<&Client>,
115+
user_dialog_peers: Option<&HashMap<i64, PeerRef>>,
78116
after_id: i32,
79117
) -> Result<(BackupBatchMetrics, i32), JobError> {
80118
let batch_size = args.batch_size.clamp(1, 200);
@@ -86,7 +124,6 @@ async fn run_backup_store_batch(
86124
return Ok((BackupBatchMetrics::default(), after_id));
87125
}
88126

89-
let (dialog_peers, _) = telegram_peer::load_dialog_peer_map(client).await;
90127
let mut metrics = BackupBatchMetrics::default();
91128
let mut last_id = after_id;
92129

@@ -96,7 +133,15 @@ async fn run_backup_store_batch(
96133
}
97134
last_id = row.id;
98135
metrics.processed += 1;
99-
match store_one(ctx, client, &dialog_peers, &row, args.capture_file_id).await {
136+
match store_one(
137+
ctx,
138+
user_client,
139+
user_dialog_peers,
140+
&row,
141+
args.capture_file_id,
142+
)
143+
.await
144+
{
100145
Ok(true) => metrics.stored += 1,
101146
Ok(false) => metrics.skipped += 1,
102147
Err(e) => {
@@ -111,12 +156,19 @@ async fn run_backup_store_batch(
111156

112157
async fn store_one(
113158
ctx: &JobCtx,
114-
client: &grammers_client::Client,
115-
dialog_peers: &std::collections::HashMap<i64, grammers_session::types::PeerRef>,
159+
user_client: Option<&Client>,
160+
user_dialog_peers: Option<&HashMap<i64, PeerRef>>,
116161
row: &TelegramStreamBackupRow,
117162
capture_file_id: bool,
118163
) -> Result<bool, String> {
119-
store_stream_to_backup(&ctx.state, client, dialog_peers, row, capture_file_id).await?;
164+
store_stream_to_backup(
165+
&ctx.state,
166+
user_client,
167+
user_dialog_peers,
168+
row,
169+
capture_file_id,
170+
)
171+
.await?;
120172
Ok(true)
121173
}
122174

@@ -127,9 +179,11 @@ impl JobHandler for TelegramBackupStore {
127179
type Args = TelegramBackupStoreArgs;
128180

129181
async fn run(&self, args: Self::Args, ctx: JobCtx) -> Result<(), JobError> {
130-
if !ctx.state.telegram_clients.api_configured() {
182+
if ctx.state.config.telegram_bot_token.is_none()
183+
&& !ctx.state.telegram_clients.api_configured()
184+
{
131185
return Err(JobError::other(
132-
"Telegram API credentials are not configured",
186+
"Configure TELEGRAM_BOT_TOKEN or Telegram API credentials with a scraping session",
133187
));
134188
}
135189
if ctx
@@ -146,9 +200,7 @@ impl JobHandler for TelegramBackupStore {
146200
}
147201

148202
let preferred = args.mediafusion_user_id.map(UserId);
149-
let (_user_id, client) = resolve_mtproto_client(&ctx.state, preferred)
150-
.await
151-
.map_err(JobError::other)?;
203+
let (user_client, user_dialog_peers) = load_user_session(&ctx, preferred).await;
152204

153205
let mut after_id = args.after_id;
154206
let mut totals = BackupBatchMetrics::default();
@@ -159,8 +211,14 @@ impl JobHandler for TelegramBackupStore {
159211
return Err(JobError::Cancelled);
160212
}
161213

162-
let (batch, last_id) =
163-
run_backup_store_batch(&ctx, &args, client.as_ref(), after_id).await?;
214+
let (batch, last_id) = run_backup_store_batch(
215+
&ctx,
216+
&args,
217+
user_client.as_deref(),
218+
user_dialog_peers.as_ref(),
219+
after_id,
220+
)
221+
.await?;
164222
if batch.processed == 0 {
165223
break;
166224
}
@@ -197,6 +255,9 @@ impl JobHandler for TelegramBackupRestore {
197255
type Args = TelegramBackupRestoreArgs;
198256

199257
async fn run(&self, args: Self::Args, ctx: JobCtx) -> Result<(), JobError> {
258+
if ctx.state.config.telegram_bot_token.is_none() {
259+
return Err(JobError::other("TELEGRAM_BOT_TOKEN is not configured"));
260+
}
200261
if !ctx.state.telegram_clients.api_configured() {
201262
return Err(JobError::other(
202263
"Telegram API credentials are not configured",
@@ -211,21 +272,21 @@ impl JobHandler for TelegramBackupRestore {
211272
.filter(|s| !s.is_empty())
212273
.ok_or_else(|| JobError::other("TELEGRAM_BACKUP_CHANNEL_ID is not configured"))?;
213274

214-
let preferred = args.mediafusion_user_id.map(UserId);
215-
let (_user_id, client) = resolve_mtproto_client(&ctx.state, preferred)
275+
let _ = args.mediafusion_user_id;
276+
let client = resolve_bot_mtproto_client(&ctx.state)
216277
.await
217278
.map_err(JobError::other)?;
218279

219-
let (dialog_peers, _) = telegram_peer::load_dialog_peer_map(&client).await;
280+
let (dialog_peers, _) = telegram_peer::load_dialog_peer_map(client.as_ref()).await;
220281
let (_, backup_peer_ref) = telegram_peer::resolve_channel_peer(
221-
&client,
282+
client.as_ref(),
222283
backup_channel,
223284
&dialog_peers,
224285
)
225286
.await
226287
.ok_or_else(|| {
227288
JobError::other(format!(
228-
"backup channel {backup_channel} is not accessible with the scraping session"
289+
"backup channel {backup_channel} is not accessible to the bot — add the bot as admin with read history"
229290
))
230291
})?;
231292

backend/src/scrapers/telegram_clients.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Per-user Telegram MTProto client pool.
1+
//! Per-user Telegram MTProto client pool and shared bot MTProto client.
22
33
use std::sync::Arc;
44
use std::time::Duration;
@@ -7,6 +7,8 @@ use grammers_client::Client;
77
use grammers_client::sender::SenderPool;
88
use grammers_session::storages::MemorySession;
99
use moka::future::Cache;
10+
use std::sync::OnceLock;
11+
use tokio::sync::Mutex;
1012
use tokio::task::JoinHandle;
1113

1214
use crate::{
@@ -30,6 +32,8 @@ pub fn is_auth_key_duplicated(err: &str) -> bool {
3032
err.contains("AUTH_KEY_DUPLICATED")
3133
}
3234

35+
static BOT_CLIENT: OnceLock<Mutex<Option<Arc<CachedClient>>>> = OnceLock::new();
36+
3337
pub struct TelegramClientPool {
3438
cache: Cache<UserId, Arc<CachedClient>>,
3539
config: AppConfig,
@@ -80,6 +84,54 @@ impl TelegramClientPool {
8084
}
8185
self.cache.invalidate(&user_id).await;
8286
}
87+
88+
/// MTProto client signed in with `TELEGRAM_BOT_TOKEN` for backup-channel operations.
89+
pub async fn get_bot_client(&self) -> Option<Arc<Client>> {
90+
if !self.api_configured() {
91+
return None;
92+
}
93+
let bot_token = self.config.telegram_bot_token.as_deref()?;
94+
let api_id = self.config.telegram_api_id?;
95+
let api_hash = self.config.telegram_api_hash.as_deref()?;
96+
97+
let slot = BOT_CLIENT.get_or_init(|| Mutex::new(None));
98+
let mut guard = slot.lock().await;
99+
if let Some(cached) = guard.as_ref() {
100+
return Some(Arc::clone(&cached.client));
101+
}
102+
103+
match build_bot_client(api_id, api_hash, bot_token).await {
104+
Ok(cached) => {
105+
let client = Arc::clone(&cached.client);
106+
*guard = Some(cached);
107+
Some(client)
108+
}
109+
Err(e) => {
110+
tracing::warn!("telegram: bot MTProto client init failed: {e}");
111+
None
112+
}
113+
}
114+
}
115+
}
116+
117+
async fn build_bot_client(
118+
api_id: i32,
119+
api_hash: &str,
120+
bot_token: &str,
121+
) -> Result<Arc<CachedClient>, Box<dyn std::error::Error + Send + Sync>> {
122+
let session = Arc::new(MemorySession::default());
123+
let pool = SenderPool::new(Arc::clone(&session) as Arc<_>, api_id);
124+
let runner = pool.runner;
125+
let handle = pool.handle;
126+
let runner_task = tokio::spawn(async move {
127+
runner.run().await;
128+
});
129+
let client = Client::new(handle);
130+
client.bot_sign_in(bot_token, api_hash).await?;
131+
Ok(Arc::new(CachedClient {
132+
client: Arc::new(client),
133+
runner: runner_task,
134+
}))
83135
}
84136

85137
async fn build_client(

0 commit comments

Comments
 (0)