use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use grammers_client::{Client, SignInError, tl};
use grammers_mtsender::SenderPool;
use grammers_session::Session as _;
use grammers_session::types::{PeerAuth, PeerInfo, UpdateState, UpdatesState};
use tokio::sync::mpsc;
use super::runner::AbortOnDrop;
use super::session::FileSession;
use super::{UserbotCreds, resolve_creds, session_file};
use crate::config::types::{Config, TelegramUserbotConfig};
pub(crate) async fn connect(
cfg: &TelegramUserbotConfig,
) -> Result<(
Client,
Arc<FileSession>,
mpsc::UnboundedReceiver<grammers_session::updates::UpdatesLike>,
AbortOnDrop,
)> {
let creds = resolve_creds(cfg)?;
let path = session_file(cfg);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating session directory {}", parent.display()))?;
}
let session = Arc::new(FileSession::load(&path)?);
#[cfg(unix)]
if path.is_file() {
use std::os::unix::fs::PermissionsExt;
if let Err(error) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
{
tracing::warn!(
path = %path.display(),
"Telegram userbot session file could not be set to 0600: {error}"
);
}
}
let SenderPool {
runner,
handle,
updates,
} = SenderPool::new(session.clone(), creds.api_id);
let client = Client::new(handle);
let runner = AbortOnDrop::new(tokio::spawn(runner.run()));
Ok((client, session, updates, runner))
}
async fn prompt_password(line: &'static str) -> Result<String> {
tokio::task::spawn_blocking(move || rpassword::prompt_password(line))
.await
.context("joining password prompt")?
.context("reading hidden password")
}
fn render_qr_terminal(token: &[u8]) -> Result<()> {
let url = format!("tg://login?token={}", URL_SAFE_NO_PAD.encode(token));
let code = qrcode::QrCode::new(url.as_bytes()).context("building QR")?;
let art = code
.render::<char>()
.quiet_zone(true)
.module_dimensions(2, 1)
.build();
println!("\n\n{art}\n");
Ok(())
}
pub(crate) async fn password_step(
client: &Client,
pass: String,
) -> Result<tl::enums::auth::Authorization> {
let tl::enums::account::Password::Password(pw) = client
.invoke(&tl::functions::account::GetPassword {})
.await
.context("account.GetPassword")?;
let algo = pw
.current_algo
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no current_algo; is a cloud password actually set?"))?;
let tl::types::PasswordKdfAlgoSha256Sha256Pbkdf2Hmacsha512iter100000Sha256ModPow {
salt1,
salt2,
p,
g,
} = match algo {
tl::enums::PasswordKdfAlgo::Sha256Sha256Pbkdf2Hmacsha512iter100000Sha256ModPow(a) => a,
tl::enums::PasswordKdfAlgo::Unknown => {
anyhow::bail!("unknown KDF algorithm; client outdated?")
}
};
let (m1, g_a) = grammers_crypto::two_factor_auth::calculate_2fa(
salt1,
salt2,
p,
g,
pw.srp_b
.clone()
.ok_or_else(|| anyhow::anyhow!("no srp_b"))?,
pw.secure_random.clone(),
pass,
);
client
.invoke(&tl::functions::auth::CheckPassword {
password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp {
srp_id: pw.srp_id.ok_or_else(|| anyhow::anyhow!("no srp_id"))?,
a: g_a.to_vec(),
m1: m1.to_vec(),
}),
})
.await
.map_err(|e| anyhow::anyhow!("2FA check failed (wrong password?): {e}"))
}
pub(crate) async fn finish(
client: &Client,
session: &Arc<FileSession>,
authorization: tl::enums::auth::Authorization,
) -> Result<String> {
let tl::enums::auth::Authorization::Authorization(auth) = authorization else {
anyhow::bail!("unexpected authorization variant");
};
let tl::enums::User::User(u) = auth.user else {
anyhow::bail!("empty user in authorization");
};
let hash = u
.access_hash
.ok_or_else(|| anyhow::anyhow!("no access_hash on self"))?;
session
.cache_peer(&PeerInfo::User {
id: u.id,
auth: Some(PeerAuth::from_hash(hash)),
bot: Some(u.bot),
is_self: Some(true),
})
.await
.context("caching self peer")?;
match client.invoke(&tl::functions::updates::GetState {}).await {
Ok(tl::enums::updates::State::State(s)) => {
if let Err(error) = session
.set_update_state(UpdateState::All(UpdatesState {
pts: s.pts,
qts: s.qts,
date: s.date,
seq: s.seq,
channels: Vec::new(),
}))
.await
{
tracing::warn!("Telegram userbot could not seed update state: {error}");
}
}
Err(error) => {
tracing::warn!("Telegram userbot updates.getState failed after login: {error}");
}
}
Ok(u.first_name.clone().unwrap_or_default())
}
pub(crate) enum QrStep {
Success(Box<tl::enums::auth::Authorization>),
PasswordNeeded,
Token(Vec<u8>),
}
pub(crate) async fn qr_poll_once(client: &Client, creds: &UserbotCreds) -> Result<QrStep> {
let res = match client
.invoke(&tl::functions::auth::ExportLoginToken {
api_id: creds.api_id,
api_hash: creds.api_hash.clone(),
except_ids: Vec::new(),
})
.await
{
Ok(res) => res,
Err(e) if e.is("SESSION_PASSWORD_NEEDED") => return Ok(QrStep::PasswordNeeded),
Err(e) => return Err(e.into()),
};
match res {
tl::enums::auth::LoginToken::Success(s) => Ok(QrStep::Success(Box::new(s.authorization))),
tl::enums::auth::LoginToken::Token(t) => Ok(QrStep::Token(t.token)),
tl::enums::auth::LoginToken::MigrateTo(m) => {
println!("migrating to DC {}…", m.dc_id);
match client
.invoke_in_dc(
m.dc_id,
&tl::functions::auth::ImportLoginToken { token: m.token },
)
.await
{
Ok(tl::enums::auth::LoginToken::Success(s)) => {
Ok(QrStep::Success(Box::new(s.authorization)))
}
Ok(other) => anyhow::bail!("import after migrate: unexpected {other:?}"),
Err(e) if e.is("SESSION_PASSWORD_NEEDED") => Ok(QrStep::PasswordNeeded),
Err(e) => Err(e.into()),
}
}
}
}
pub(crate) async fn qr_login(
client: Client,
session: Arc<FileSession>,
creds: &UserbotCreds,
) -> Result<String> {
let mut last: Vec<u8> = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(180);
loop {
anyhow::ensure!(
tokio::time::Instant::now() < deadline,
"timed out waiting for QR scan"
);
match qr_poll_once(&client, creds).await? {
QrStep::Success(auth) => return finish(&client, &session, *auth).await,
QrStep::PasswordNeeded => {
let pass = prompt_password("2FA cloud password: ").await?;
let auth = password_step(&client, pass).await?;
return finish(&client, &session, auth).await;
}
QrStep::Token(t) => {
if t != last {
last = t.clone();
render_qr_terminal(&t)?;
println!(
"Scan with your phone: Telegram > Settings > Devices > Link Desktop Device\n(valid ~3 min; re-renders automatically)"
);
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
pub(crate) async fn code_login(client: Client, creds: &UserbotCreds) -> Result<String> {
let token = client
.request_login_code(&creds.phone, &creds.api_hash)
.await
.context("requesting login code")?;
let code = prompt_password("Login code (sent to Telegram): ").await?;
match client.sign_in(&token, &code).await {
Ok(u) => Ok(u.first_name().unwrap_or_default().to_string()),
Err(SignInError::PasswordRequired(pw)) => {
let pass = prompt_password("2FA cloud password: ").await?;
let user = client.check_password(pw, pass).await?;
Ok(user.first_name().unwrap_or_default().to_string())
}
Err(e) => Err(e.into()),
}
}
pub(crate) async fn cmd_userbot_login(config: &Config, use_code: bool) -> Result<()> {
let cfg = &config.channels.telegram.userbot;
let creds = resolve_creds(cfg)?;
let path = session_file(cfg);
println!("userbot session file: {}", path.display());
let (client, session, updates, runner) = connect(cfg).await?;
if client.is_authorized().await? {
let me = client.get_me().await?;
println!(
"✅ already authorized as {}",
me.first_name().unwrap_or("?")
);
drop(updates);
drop(runner);
return Ok(());
}
let name = if use_code {
code_login(client.clone(), &creds).await?
} else {
qr_login(client.clone(), session.clone(), &creds).await?
};
session.save()?;
drop(updates);
drop(runner);
println!("✅ authorized as {name}");
println!(
"The session file grants full account access. Treat it like keys.toml.\n\
Restart opencrabs (or toggle channels.telegram.userbot.enabled) to start the watch loop."
);
Ok(())
}
pub(crate) fn session_exists(config: &TelegramUserbotConfig) -> bool {
session_file(config).is_file()
}