use std::ffi::{CStr, CString, c_char};
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use serde_json::json;
use tdlib_rs::{enums, functions, types};
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::error::SendError;
use crate::chats::{Badge, List, Peer};
use crate::config::{ApiKeys, Config};
use crate::info::{self, About, Member};
use crate::messages::Sender;
use crate::reactions::{self, Available, ReactionKind};
use crate::search::{Has, Query, Who};
use crate::stickers::{Source, Sticker};
use crate::topics::{self, Offset};
pub enum TgEvent {
Update(Box<enums::Update>),
Error(String),
ChatsLoaded {
list: List,
all: bool,
failed: bool,
},
History {
chat_id: i64,
topic: Option<i32>,
page: Page,
messages: Option<Vec<types::Message>>,
},
Found {
chat_id: i64,
topic: Option<i32>,
query: String,
found: Option<Found>,
},
Replied {
chat_id: i64,
message_id: i64,
replied: Option<Box<types::Message>>,
},
Pinned {
chat_id: i64,
topic: Option<i32>,
request: u32,
messages: Option<Vec<types::Message>>,
},
Topics {
chat_id: i64,
request: u32,
page: Option<types::ForumTopics>,
},
Topic {
chat_id: i64,
topic_id: i32,
topic: Option<Box<types::ForumTopic>>,
},
Pinnable {
chat_id: i64,
message_id: i64,
pinnable: Option<bool>,
},
Deletable {
chat_id: i64,
message_id: i64,
deletable: Option<Deletable>,
},
Editable {
chat_id: i64,
message_id: i64,
editable: Option<bool>,
text: Option<EditText>,
},
Reactions {
chat_id: i64,
message_id: i64,
available: Option<Available>,
},
Stickers {
source: Source,
stickers: Option<Vec<types::Sticker>>,
},
StickerSets(Option<Vec<(i64, String)>>),
StickersFound {
query: String,
stickers: Option<Vec<types::Sticker>>,
},
Downloaded {
file_id: i32,
path: Option<String>,
},
ChatsFound {
query: String,
chat_ids: Vec<i64>,
user_ids: Vec<i64>,
},
ChatFound {
request: String,
found: Result<Spot, Missed>,
},
Members {
chat_id: i64,
query: String,
user_ids: Vec<i64>,
},
Commands {
chat_id: i64,
commands: Vec<crate::complete::Command>,
},
Forwarded {
chat_id: i64,
},
Joined {
chat_id: i64,
},
Left {
chat_id: i64,
},
BotAnswer {
chat_id: i64,
topic: Option<i32>,
label: String,
text: String,
alert: bool,
url: String,
},
Invite {
request: String,
link: String,
invite: Invite,
},
Mentions {
chat_id: i64,
topic: Option<i32>,
ask: Mentions,
ids: Option<Vec<i64>>,
},
ChatInfo {
chat_id: i64,
about: Option<Box<About>>,
},
ChatMembers {
chat_id: i64,
offset: i32,
members: Option<Vec<Member>>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Spot {
pub chat_id: i64,
pub topic: Option<i32>,
pub message_id: Option<i64>,
}
impl Spot {
pub fn chat(chat_id: i64) -> Self {
Self {
chat_id,
topic: None,
message_id: None,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Missed {
Said(String),
Quiet,
Elsewhere,
}
impl From<String> for Missed {
fn from(why: String) -> Self {
Missed::Said(why)
}
}
pub struct Invite {
pub title: String,
pub members: i32,
pub channel: bool,
pub badge: Option<Badge>,
pub by_request: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
Latest,
Older(i64),
Newer(i64),
Around(i64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mentions {
Unread,
Before(i64),
After(i64),
}
impl Mentions {
pub fn pick(self, ids: &[i64]) -> Option<i64> {
let ids = ids.iter().copied();
match self {
Mentions::Unread => ids.min(),
Mentions::Before(from) => ids.filter(|&id| from == 0 || id < from).max(),
Mentions::After(from) => ids.filter(|&id| id > from).min(),
}
}
}
pub struct Found {
pub ids: Vec<i64>,
pub total: i32,
pub next_from: i64,
pub next_offset: String,
}
#[derive(Clone, Copy)]
pub struct Deletable {
pub for_everyone: bool,
pub for_me: bool,
}
const DOWNLOAD_PRIORITY: i32 = 16;
const QUIET_DOWNLOAD_PRIORITY: i32 = 8;
const NOTIFICATION_GROUPS: i64 = 5;
const PINNED_PAGE: i32 = 100;
const PINNED_PAGES: usize = 5;
const STICKER_SEARCH_LIMIT: i32 = 100;
const MENTIONS_PAGE: i32 = 100;
const MEMBER_SEARCH_LIMIT: i32 = 20;
const CONTACT_SEARCH_LIMIT: i32 = 20;
const OFFLINE_GRACE: Duration = Duration::from_millis(300);
static CLIENT: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" {
fn td_execute(request: *const c_char) -> *const c_char;
fn td_send(client_id: i32, request: *const c_char);
fn OPENSSL_init_crypto(opts: u64, settings: *const std::ffi::c_void) -> std::ffi::c_int;
}
const OPENSSL_INIT_NO_LOAD_CONFIG: u64 = 0x80;
fn no_openssl_config() -> Result<()> {
let done = unsafe { OPENSSL_init_crypto(OPENSSL_INIT_NO_LOAD_CONFIG, std::ptr::null()) };
anyhow::ensure!(done == 1, "OpenSSL couldn't be set up");
Ok(())
}
pub fn offline_now() {
let client_id = CLIENT.load(Ordering::Relaxed);
if client_id == 0 {
return;
}
let request = json!({
"@type": "setOption",
"name": "online",
"value": { "@type": "optionValueBoolean", "value": false },
});
let Ok(request) = CString::new(request.to_string()) else {
return;
};
unsafe { td_send(client_id, request.as_ptr()) };
std::thread::sleep(OFFLINE_GRACE);
}
fn log_to_file(path: &Path) -> Result<()> {
let requests = [
json!({
"@type": "setLogStream",
"log_stream": {
"@type": "logStreamFile",
"path": path.to_string_lossy(),
"max_file_size": 10 * 1024 * 1024,
"redirect_stderr": false,
},
}),
json!({ "@type": "setLogVerbosityLevel", "new_verbosity_level": 2 }),
];
for request in requests {
let response = execute(&request)?;
if !response.contains(r#""@type":"ok""#) {
bail!("TDLib log setup failed: {response}");
}
}
Ok(())
}
fn execute(request: &serde_json::Value) -> Result<String> {
let request = CString::new(request.to_string())?;
let response = unsafe {
let response = td_execute(request.as_ptr());
if response.is_null() {
String::new()
} else {
CStr::from_ptr(response).to_string_lossy().into_owned()
}
};
Ok(response)
}
fn execute_text(request: &serde_json::Value) -> Option<types::FormattedText> {
let response = execute(request).ok()?;
match serde_json::from_str(&response) {
Ok(enums::FormattedText::FormattedText(text)) => Some(text),
Err(_) => None,
}
}
pub fn markdown(text: String) -> types::FormattedText {
let request = json!({
"@type": "parseMarkdown",
"text": { "@type": "formattedText", "text": text, "entities": [] },
});
let mut parsed = execute_text(&request).unwrap_or_else(|| plain(text));
for entity in &mut parsed.entities {
if let enums::TextEntityType::TextUrl(link) = &entity.r#type
&& let Some(user_id) = link
.url
.strip_prefix("tg://user?id=")
.and_then(|id| id.parse().ok())
{
entity.r#type =
enums::TextEntityType::MentionName(types::TextEntityTypeMentionName { user_id });
}
}
parsed
}
pub struct EditText {
pub markdown: String,
pub loses: bool,
}
pub fn to_markdown(text: &types::FormattedText) -> Option<EditText> {
let request = json!({ "@type": "getMarkdownText", "text": text });
let written = execute_text(&request)?;
let mut markdown = written.text.clone();
let mut loses = false;
let mut left = written.entities.clone();
left.sort_by_key(|e| std::cmp::Reverse(e.offset));
for entity in left {
match &entity.r#type {
enums::TextEntityType::MentionName(mention) => {
let start = crate::messages::byte_offset(&written.text, entity.offset);
let end = crate::messages::byte_offset(
&written.text,
entity.offset.saturating_add(entity.length),
);
if start < end {
markdown.insert_str(end, &format!("](tg://user?id={})", mention.user_id));
markdown.insert(start, '[');
}
}
kind if !crate::messages::found_by_telegram(kind) => loses = true,
_ => {}
}
}
Some(EditText {
markdown: crate::text::clean(&markdown),
loses,
})
}
fn editable_text(content: &enums::MessageContent) -> Option<&types::FormattedText> {
use enums::MessageContent as C;
match content {
C::MessageText(m) => Some(&m.text),
C::MessagePhoto(m) => Some(&m.caption),
C::MessageVideo(m) => Some(&m.caption),
C::MessageAnimation(m) => Some(&m.caption),
C::MessageDocument(m) => Some(&m.caption),
C::MessageAudio(m) => Some(&m.caption),
C::MessageVoiceNote(m) => Some(&m.caption),
_ => None,
}
}
pub type Tagged = (i32, TgEvent);
#[derive(Clone)]
struct Events {
client_id: i32,
tx: UnboundedSender<Tagged>,
}
impl Events {
fn send(&self, event: TgEvent) -> Result<(), SendError<Tagged>> {
self.tx.send((self.client_id, event))
}
}
#[derive(Clone)]
pub struct Tg {
client_id: i32,
tx: Events,
config: Arc<Config>,
accept: Arc<AcceptSecretChats>,
}
#[derive(Default)]
struct AcceptSecretChats {
wanted: AtomicBool,
applying: tokio::sync::Mutex<()>,
}
impl Tg {
pub async fn start(config: Config, tx: UnboundedSender<Tagged>) -> Result<Self> {
no_openssl_config()?;
log_to_file(&config.data_dir.join("tdlib.log"))?;
let client_id = tdlib_rs::create_client();
CLIENT.store(client_id, Ordering::Relaxed);
std::thread::spawn({
let tx = tx.clone();
move || {
loop {
if let Some((update, client_id)) = tdlib_rs::receive()
&& tx
.send((client_id, TgEvent::Update(Box::new(update))))
.is_err()
{
break;
}
}
}
});
functions::get_option("version".into(), client_id)
.await
.map_err(|e| anyhow!("TDLib didn't start: {}", e.message))?;
Ok(Self {
client_id,
tx: Events { client_id, tx },
config: Arc::new(config),
accept: Arc::default(),
})
}
pub fn detached(tx: UnboundedSender<Tagged>) -> Self {
let client_id = 0;
Self {
client_id,
tx: Events { client_id, tx },
config: Arc::new(Config {
api_keys: None,
data_dir: std::env::temp_dir(),
}),
accept: Arc::default(),
}
}
pub fn client_id(&self) -> i32 {
self.client_id
}
pub fn set_tdlib_parameters(&self, keys: ApiKeys) {
let config = Arc::clone(&self.config);
let client_id = self.client_id;
self.spawn(async move {
let dir = |name: &str| config.data_dir.join(name).to_string_lossy().into_owned();
functions::set_tdlib_parameters(
false,
dir("db"),
dir("files"),
String::new(), true, true, true, true, keys.id,
keys.hash,
"en".into(),
"Terminal".into(), std::env::consts::OS.into(),
env!("CARGO_PKG_VERSION").into(),
client_id,
)
.await
});
}
pub fn send_phone_number(&self, phone: String) {
self.spawn(functions::set_authentication_phone_number(
phone,
None,
self.client_id,
));
}
pub fn send_code(&self, code: String) {
self.spawn(functions::check_authentication_code(code, self.client_id));
}
pub fn send_password(&self, password: String) {
self.spawn(functions::check_authentication_password(
password,
self.client_id,
));
}
pub fn send_email(&self, email: String) {
self.spawn(functions::set_authentication_email_address(
email,
self.client_id,
));
}
pub fn request_qr_code(&self) {
self.spawn(functions::request_qr_code_authentication(
Vec::new(),
self.client_id,
));
}
pub fn send_email_code(&self, code: String) {
self.spawn(functions::check_authentication_email_code(
enums::EmailAddressAuthentication::Code(types::EmailAddressAuthenticationCode { code }),
self.client_id,
));
}
pub fn load_chats(&self, list: List, limit: i32) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let (all, failed) =
match functions::load_chats(Some(list.tdlib()), limit, client_id).await {
Ok(()) => (false, false),
Err(e) if e.code == 404 => (true, false),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
(false, true)
}
};
let _ = tx.send(TgEvent::ChatsLoaded { list, all, failed });
});
}
pub fn open_chat(&self, chat_id: i64) {
self.spawn(functions::open_chat(chat_id, self.client_id));
}
pub fn close_chat(&self, chat_id: i64) {
self.spawn(functions::close_chat(chat_id, self.client_id));
}
pub fn load_history(&self, chat_id: i64, topic: Option<i32>, page: Page, limit: i32) {
let (from, offset) = match page {
Page::Latest => (0, 0),
Page::Older(id) => (id, 0),
Page::Newer(id) => (id, 1 - limit),
Page::Around(id) => (id, -limit / 2),
};
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = match topic {
Some(topic) => {
functions::get_forum_topic_history(
chat_id, topic, from, offset, limit, client_id,
)
.await
}
None => {
functions::get_chat_history(chat_id, from, offset, limit, false, client_id)
.await
}
};
let messages = match result {
Ok(enums::Messages::Messages(page)) => {
Some(page.messages.into_iter().flatten().collect())
}
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::History {
chat_id,
topic,
page,
messages,
});
});
}
#[allow(clippy::too_many_arguments)]
pub fn search_messages(
&self,
chat_id: i64,
topic: Option<i32>,
secret: bool,
query: String,
ask: Query,
from: i64,
offset: String,
limit: i32,
) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let found = match secret && !ask.words.is_empty() {
true => secret_search_page(chat_id, &ask, offset, limit, client_id).await,
false => search_page(chat_id, topic, &ask, from, limit, client_id).await,
};
let found = match found {
Ok(found) => Some(found),
Err(why) => {
let _ = tx.send(TgEvent::Error(why));
None
}
};
let _ = tx.send(TgEvent::Found {
chat_id,
topic,
query,
found,
});
});
}
pub fn pinned_messages(&self, chat_id: i64, topic: Option<i32>, request: u32) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let mut messages = Vec::new();
let mut from = 0;
for _ in 0..PINNED_PAGES {
let result = functions::search_chat_messages(
chat_id,
forum_topic(topic),
String::new(),
None,
from,
0,
PINNED_PAGE,
Some(enums::SearchMessagesFilter::Pinned),
client_id,
)
.await;
match result {
Ok(enums::FoundChatMessages::FoundChatMessages(found)) => {
messages.extend(found.messages);
from = found.next_from_message_id;
if from == 0 {
break;
}
}
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
let _ = tx.send(TgEvent::Pinned {
chat_id,
topic,
request,
messages: None,
});
return;
}
}
}
let _ = tx.send(TgEvent::Pinned {
chat_id,
topic,
request,
messages: Some(messages),
});
});
}
pub fn mentions(&self, chat_id: i64, topic: Option<i32>, general: i32, ask: Mentions) {
let tx = self.tx.clone();
let client_id = self.client_id;
let (filter, from, offset) = match ask {
Mentions::Unread => (enums::SearchMessagesFilter::UnreadMention, 0, 0),
Mentions::Before(id) => (enums::SearchMessagesFilter::Mention, id, 0),
Mentions::After(id) => (enums::SearchMessagesFilter::Mention, id, 1 - MENTIONS_PAGE),
};
tokio::spawn(async move {
let search = |topic| {
functions::search_chat_messages(
chat_id,
forum_topic(topic),
String::new(),
None,
from,
offset,
MENTIONS_PAGE,
Some(filter.clone()),
client_id,
)
};
let mut result = search(topic).await;
if result.is_err() && topic.is_some() {
result = search(None).await;
}
let ids = match result {
Ok(enums::FoundChatMessages::FoundChatMessages(found)) => Some(
found
.messages
.iter()
.filter(|m| topic.is_none_or(|t| topic_of(m, general) == t))
.map(|m| m.id)
.collect(),
),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Mentions {
chat_id,
topic,
ask,
ids,
});
});
}
pub fn chat_info(&self, chat_id: i64, peer: Peer) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let about = match peer {
Peer::User(id) => match functions::get_user(id, client_id).await {
Ok(enums::User::User(user)) => {
let full = functions::get_user_full_info(id, client_id).await;
let full = full.ok().map(|enums::UserFullInfo::UserFullInfo(f)| f);
Ok(About::of_user(&user, full.as_ref()))
}
Err(e) => Err(e),
},
Peer::BasicGroup(id) => match functions::get_basic_group(id, client_id).await {
Ok(enums::BasicGroup::BasicGroup(group)) => {
let full = functions::get_basic_group_full_info(id, client_id).await;
let full = full
.ok()
.map(|enums::BasicGroupFullInfo::BasicGroupFullInfo(f)| f);
Ok(About::of_basic_group(&group, full.as_ref()))
}
Err(e) => Err(e),
},
Peer::Supergroup(id) => match functions::get_supergroup(id, client_id).await {
Ok(enums::Supergroup::Supergroup(group)) => {
let full = functions::get_supergroup_full_info(id, client_id).await;
let full = full
.ok()
.map(|enums::SupergroupFullInfo::SupergroupFullInfo(f)| f);
Ok(About::of_supergroup(&group, full.as_ref()))
}
Err(e) => Err(e),
},
};
let about = match about {
Ok(about) => Some(Box::new(about)),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::ChatInfo { chat_id, about });
});
}
pub fn chat_members(&self, chat_id: i64, supergroup_id: i64, offset: i32) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_supergroup_members(
supergroup_id,
None,
offset,
info::MEMBERS_PAGE,
client_id,
)
.await;
let members = match result {
Ok(enums::ChatMembers::ChatMembers(page)) => {
Some(page.members.iter().filter_map(Member::of).collect())
}
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::ChatMembers {
chat_id,
offset,
members,
});
});
}
pub fn check_pinnable(&self, chat_id: i64, message_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_message_properties(chat_id, message_id, client_id).await;
let pinnable = match result {
Ok(enums::MessageProperties::MessageProperties(p)) => Some(p.can_be_pinned),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Pinnable {
chat_id,
message_id,
pinnable,
});
});
}
pub fn pin_message(&self, chat_id: i64, message_id: i64, quietly: bool, only_for_self: bool) {
self.spawn(functions::pin_chat_message(
chat_id,
message_id,
quietly,
only_for_self,
self.client_id,
));
}
pub fn unpin_message(&self, chat_id: i64, message_id: i64) {
self.spawn(functions::unpin_chat_message(
chat_id,
message_id,
self.client_id,
));
}
pub fn get_replied_message(&self, chat_id: i64, message_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let replied = functions::get_replied_message(chat_id, message_id, client_id)
.await
.ok()
.map(|enums::Message::Message(m)| Box::new(m));
let _ = tx.send(TgEvent::Replied {
chat_id,
message_id,
replied,
});
});
}
pub fn check_deletable(&self, chat_id: i64, message_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_message_properties(chat_id, message_id, client_id).await;
let deletable = match result {
Ok(enums::MessageProperties::MessageProperties(p)) => Some(Deletable {
for_everyone: p.can_be_deleted_for_all_users,
for_me: p.can_be_deleted_only_for_self,
}),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Deletable {
chat_id,
message_id,
deletable,
});
});
}
pub fn available_reactions(&self, chat_id: i64, message_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let row = reactions::COLUMNS as i32;
let result =
functions::get_message_available_reactions(chat_id, message_id, row, client_id)
.await;
let available = match result {
Ok(enums::AvailableReactions::AvailableReactions(r)) => Some(Available::from(r)),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Reactions {
chat_id,
message_id,
available,
});
});
}
pub fn react(&self, chat_id: i64, message_id: i64, kind: &ReactionKind, add: bool) {
let Some(reaction) = kind.to_tdlib() else {
return;
};
if add {
self.spawn(functions::add_message_reaction(
chat_id,
message_id,
reaction,
false,
true,
self.client_id,
));
} else {
self.spawn(functions::remove_message_reaction(
chat_id,
message_id,
reaction,
self.client_id,
));
}
}
pub fn check_editable(&self, chat_id: i64, message_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_message_properties(chat_id, message_id, client_id).await;
let editable = match result {
Ok(enums::MessageProperties::MessageProperties(p)) => Some(p.can_be_edited),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let mut text = None;
if editable == Some(true)
&& let Ok(enums::Message::Message(message)) =
functions::get_message(chat_id, message_id, client_id).await
{
text = editable_text(&message.content).and_then(to_markdown);
}
let _ = tx.send(TgEvent::Editable {
chat_id,
message_id,
editable,
text,
});
});
}
pub fn edit_text(&self, chat_id: i64, message_id: i64, text: String) {
let content = enums::InputMessageContent::InputMessageText(types::InputMessageText {
text: markdown(text),
link_preview_options: None,
clear_draft: false,
});
self.spawn(functions::edit_message_text(
chat_id,
message_id,
content,
self.client_id,
));
}
pub fn edit_caption(&self, chat_id: i64, message_id: i64, text: String, above: bool) {
self.spawn(functions::edit_message_caption(
chat_id,
message_id,
Some(markdown(text)),
above,
self.client_id,
));
}
pub fn delete_message(&self, chat_id: i64, message_id: i64, revoke: bool) {
self.spawn(functions::delete_messages(
chat_id,
vec![message_id],
revoke,
self.client_id,
));
}
pub fn send_text(
&self,
chat_id: i64,
topic: Option<i32>,
text: String,
reply_to: Option<i64>,
secret: bool,
) {
self.send_formatted(chat_id, topic, markdown(text), reply_to, secret);
}
pub fn send_plain(
&self,
chat_id: i64,
topic: Option<i32>,
text: String,
reply_to: Option<i64>,
) {
self.send_formatted(chat_id, topic, plain(text), reply_to, false);
}
fn send_formatted(
&self,
chat_id: i64,
topic: Option<i32>,
text: types::FormattedText,
reply_to: Option<i64>,
no_preview: bool,
) {
let link_preview_options = no_preview.then(|| types::LinkPreviewOptions {
is_disabled: true,
..Default::default()
});
let content = enums::InputMessageContent::InputMessageText(types::InputMessageText {
text,
link_preview_options,
clear_draft: true,
});
let reply_to = reply_to.map(reply_to_message);
self.spawn(functions::send_message(
chat_id,
forum_topic(topic),
reply_to,
None,
content,
self.client_id,
));
}
pub fn send_sticker(
&self,
chat_id: i64,
topic: Option<i32>,
sticker: &Sticker,
reply_to: Option<i64>,
) {
let content = enums::InputMessageContent::InputMessageSticker(types::InputMessageSticker {
sticker: enums::InputFile::Id(types::InputFileId {
id: sticker.file_id,
}),
thumbnail: None,
width: sticker.width,
height: sticker.height,
emoji: sticker.emoji.clone(),
});
let reply_to = reply_to.map(reply_to_message);
self.spawn(functions::send_message(
chat_id,
forum_topic(topic),
reply_to,
None,
content,
self.client_id,
));
}
pub fn stickers(&self, source: Source) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = match source {
Source::Recent => functions::get_recent_stickers(false, client_id)
.await
.map(|enums::Stickers::Stickers(s)| s.stickers),
Source::Favorites => functions::get_favorite_stickers(client_id)
.await
.map(|enums::Stickers::Stickers(s)| s.stickers),
Source::Set(id) => functions::get_sticker_set(id, client_id)
.await
.map(|enums::StickerSet::StickerSet(s)| s.stickers),
};
let stickers = match result {
Ok(stickers) => Some(stickers),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Stickers { source, stickers });
});
}
pub fn sticker_sets(&self) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result =
functions::get_installed_sticker_sets(enums::StickerType::Regular, client_id).await;
let sets = match result {
Ok(enums::StickerSets::StickerSets(s)) => {
Some(s.sets.into_iter().map(|set| (set.id, set.title)).collect())
}
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::StickerSets(sets));
});
}
pub fn find_stickers(&self, chat_id: i64, query: String) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_stickers(
enums::StickerType::Regular,
query.clone(),
STICKER_SEARCH_LIMIT,
chat_id,
client_id,
)
.await;
let stickers = match result {
Ok(enums::Stickers::Stickers(s)) => Some(s.stickers),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::StickersFound { query, stickers });
});
}
pub fn send_files(
&self,
chat_id: i64,
topic: Option<i32>,
groups: Vec<Vec<Upload>>,
caption: String,
reply_to: Option<i64>,
) {
let tx = self.tx.clone();
let client_id = self.client_id;
let mut caption = (!caption.is_empty()).then(|| markdown(caption));
tokio::spawn(async move {
let mut reply_to = reply_to.map(reply_to_message);
for group in groups {
let mut contents: Vec<_> = group
.into_iter()
.map(|upload| upload.content(caption.take()))
.collect();
let reply_to = reply_to.take();
let result = if contents.len() == 1 {
let content = contents.remove(0);
let topic = forum_topic(topic);
functions::send_message(chat_id, topic, reply_to, None, content, client_id)
.await
.map(drop)
} else {
functions::send_message_album(
chat_id,
forum_topic(topic),
reply_to,
None,
contents,
client_id,
)
.await
.map(drop)
};
if let Err(e) = result {
let _ = tx.send(TgEvent::Error(e.message));
break;
}
}
});
}
pub fn forward(&self, chat_id: i64, from_chat_id: i64, message_ids: Vec<i64>) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::forward_messages(
chat_id,
None,
from_chat_id,
message_ids,
None,
false, false, client_id,
)
.await;
let _ = match result {
Ok(_) => tx.send(TgEvent::Forwarded { chat_id }),
Err(e) => tx.send(TgEvent::Error(e.message)),
};
});
}
pub fn find_chats(&self, query: String) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let user_ids =
match functions::search_contacts(query.clone(), CONTACT_SEARCH_LIMIT, client_id)
.await
{
Ok(enums::Users::Users(u)) => u.user_ids,
Err(_) => Vec::new(),
};
let chat_ids = match functions::search_public_chats(query.clone(), client_id).await {
Ok(enums::Chats::Chats(c)) => c.chat_ids,
Err(_) => Vec::new(),
};
let _ = tx.send(TgEvent::ChatsFound {
query,
chat_ids,
user_ids,
});
});
}
pub fn find_private_chat(&self, user_id: i64, request: String) {
let client_id = self.client_id;
self.find(request, async move {
let enums::Chat::Chat(chat) = functions::create_private_chat(user_id, false, client_id)
.await
.map_err(|e| Missed::Said(e.message))?;
Ok(Spot::chat(chat.id))
});
}
pub fn find_username(&self, username: String, request: String) {
let client_id = self.client_id;
self.find(request, async move {
let id = public_chat(&username, client_id).await?;
Ok(Spot::chat(id))
});
}
pub fn find_link(&self, link: String, request: String) {
let tx = self.tx.clone();
let client_id = self.client_id;
self.find(request.clone(), async move {
let kind = functions::get_internal_link_type(link.clone(), client_id)
.await
.map_err(|_| Missed::Elsewhere)?;
match kind {
enums::InternalLinkType::PublicChat(p) => {
Ok(Spot::chat(public_chat(&p.chat_username, client_id).await?))
}
enums::InternalLinkType::Message(m) => {
let enums::MessageLinkInfo::MessageLinkInfo(info) =
functions::get_message_link_info(m.url, client_id)
.await
.map_err(|e| Missed::Said(e.message))?;
if info.chat_id == 0 {
return Err(Missed::Said("That message can't be found".into()));
}
let topic = match info.topic_id {
Some(enums::MessageTopic::Forum(t)) => Some(t.forum_topic_id),
_ => None,
};
Ok(Spot {
chat_id: info.chat_id,
topic,
message_id: info.message.map(|m| m.id),
})
}
enums::InternalLinkType::ChatInvite(i) => {
let enums::ChatInviteLinkInfo::ChatInviteLinkInfo(info) =
functions::check_chat_invite_link(i.invite_link.clone(), client_id)
.await
.map_err(|e| Missed::Said(e.message))?;
if info.chat_id != 0 && info.accessible_for == 0 {
return Ok(Spot::chat(info.chat_id));
}
let invite = Invite {
title: crate::text::clean(&info.title),
members: info.member_count,
channel: matches!(info.r#type, enums::InviteLinkChatType::Channel),
badge: Badge::of(info.verification_status.as_ref(), false),
by_request: info.creates_join_request,
};
let _ = tx.send(TgEvent::Invite {
request,
link: i.invite_link,
invite,
});
Err(Missed::Quiet)
}
_ => Err(Missed::Elsewhere),
}
});
}
pub fn join_by_link(&self, link: String, request: String) {
let client_id = self.client_id;
self.find(request, async move {
let enums::Chat::Chat(chat) = functions::join_chat_by_invite_link(link, client_id)
.await
.map_err(|e| Missed::Said(e.message))?;
Ok(Spot::chat(chat.id))
});
}
pub fn join_chat(&self, chat_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let _ = match functions::join_chat(chat_id, client_id).await {
Ok(()) => tx.send(TgEvent::Joined { chat_id }),
Err(e) => tx.send(TgEvent::Error(e.message)),
};
});
}
pub fn find_members(&self, chat_id: i64, query: String) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::search_chat_members(
chat_id,
query.clone(),
MEMBER_SEARCH_LIMIT,
None,
client_id,
)
.await;
let user_ids = match result {
Ok(enums::ChatMembers::ChatMembers(m)) => m
.members
.into_iter()
.filter_map(|m| match m.member_id {
enums::MessageSender::User(u) => Some(u.user_id),
enums::MessageSender::Chat(_) => None,
})
.collect(),
Err(_) => Vec::new(),
};
let _ = tx.send(TgEvent::Members {
chat_id,
query,
user_ids,
});
});
}
pub fn bot_commands(&self, chat_id: i64, peer: Peer) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let by_bot: Vec<(i64, Vec<types::BotCommand>)> = match peer {
Peer::User(id) => match functions::get_user_full_info(id, client_id).await {
Ok(enums::UserFullInfo::UserFullInfo(info)) => info
.bot_info
.map(|b| vec![(id, b.commands)])
.unwrap_or_default(),
Err(_) => Vec::new(),
},
Peer::BasicGroup(id) => {
match functions::get_basic_group_full_info(id, client_id).await {
Ok(enums::BasicGroupFullInfo::BasicGroupFullInfo(info)) => info
.bot_commands
.into_iter()
.map(|b| (b.bot_user_id, b.commands))
.collect(),
Err(_) => Vec::new(),
}
}
Peer::Supergroup(id) => {
match functions::get_supergroup_full_info(id, client_id).await {
Ok(enums::SupergroupFullInfo::SupergroupFullInfo(info)) => info
.bot_commands
.into_iter()
.map(|b| (b.bot_user_id, b.commands))
.collect(),
Err(_) => Vec::new(),
}
}
};
let line = |text: &str| {
crate::text::clean(text)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
};
let commands = by_bot
.into_iter()
.flat_map(|(bot, commands)| {
commands.into_iter().map(move |c| crate::complete::Command {
bot,
name: line(&c.command),
description: line(&c.description),
})
})
.collect();
let _ = tx.send(TgEvent::Commands { chat_id, commands });
});
}
pub fn pin_chat(&self, list: List, chat_id: i64, pinned: bool) {
self.spawn(functions::toggle_chat_is_pinned(
list.tdlib(),
chat_id,
pinned,
self.client_id,
));
}
pub fn set_notifications(&self, chat_id: i64, settings: types::ChatNotificationSettings) {
self.spawn(functions::set_chat_notification_settings(
chat_id,
settings,
self.client_id,
));
}
pub fn vote(&self, chat_id: i64, message_id: i64, answers: Vec<i32>) {
self.spawn(functions::set_poll_answer(
chat_id,
message_id,
answers,
self.client_id,
));
}
pub fn press_button(
&self,
chat_id: i64,
topic: Option<i32>,
message_id: i64,
data: String,
label: String,
) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let payload =
enums::CallbackQueryPayload::Data(types::CallbackQueryPayloadData { data });
let answer =
functions::get_callback_query_answer(chat_id, message_id, payload, client_id).await;
let _ = match answer {
Ok(enums::CallbackQueryAnswer::CallbackQueryAnswer(a)) => {
tx.send(TgEvent::BotAnswer {
chat_id,
topic,
label,
text: a.text,
alert: a.show_alert,
url: a.url,
})
}
Err(e) => tx.send(TgEvent::Error(e.message)),
};
});
}
pub fn start_secret_chat(&self, user_id: i64, request: String) {
let client_id = self.client_id;
self.find(request, async move {
let enums::Chat::Chat(chat) = functions::create_new_secret_chat(user_id, client_id)
.await
.map_err(|e| Missed::Said(e.message))?;
Ok(Spot::chat(chat.id))
});
}
pub fn end_secret_chat(&self, chat_id: i64, secret_id: i32, open: bool) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let ended = async {
if open {
functions::close_secret_chat(secret_id, client_id).await?;
}
functions::delete_chat_history(chat_id, true, false, client_id).await
};
let _ = match ended.await {
Ok(()) => tx.send(TgEvent::Left { chat_id }),
Err(e) => tx.send(TgEvent::Error(e.message)),
};
});
}
pub fn set_timer(&self, chat_id: i64, seconds: i32) {
self.spawn(functions::set_chat_message_auto_delete_time(
chat_id,
seconds,
self.client_id,
));
}
pub fn open_content(&self, chat_id: i64, message_id: i64) {
self.spawn(functions::open_message_content(
chat_id,
message_id,
self.client_id,
));
}
pub fn accept_secret_chats(&self, accept: bool) {
self.accept.wanted.store(accept, Ordering::Relaxed);
let state = Arc::clone(&self.accept);
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let _applying = state.applying.lock().await;
let accept = state.wanted.load(Ordering::Relaxed);
let set = async {
let enums::Sessions::Sessions(sessions) =
functions::get_active_sessions(client_id).await?;
match sessions.sessions.into_iter().find(|s| s.is_current) {
Some(this) if this.can_accept_secret_chats != accept => {
functions::toggle_session_can_accept_secret_chats(
this.id, accept, client_id,
)
.await
}
_ => Ok(()),
}
};
if let Err(e) = set.await {
let what = if accept {
"take"
} else {
"leave to your phone"
};
let why = format!(
"Couldn't {what} the secret chats others start: {}",
e.message
);
let _ = tx.send(TgEvent::Error(why));
}
});
}
pub fn leave_chat(&self, chat_id: i64) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let _ = match functions::leave_chat(chat_id, client_id).await {
Ok(()) => tx.send(TgEvent::Left { chat_id }),
Err(e) => tx.send(TgEvent::Error(e.message)),
};
});
}
fn find(
&self,
request: String,
lookup: impl Future<Output = Result<Spot, Missed>> + Send + 'static,
) {
let tx = self.tx.clone();
tokio::spawn(async move {
let found = lookup.await;
let _ = tx.send(TgEvent::ChatFound { request, found });
});
}
pub fn send_typing(&self, chat_id: i64, topic: Option<i32>, typing: bool) {
let action = typing.then_some(enums::ChatAction::Typing);
let client_id = self.client_id;
tokio::spawn(async move {
let topic = forum_topic(topic);
let _ = functions::send_chat_action(chat_id, topic, action, client_id).await;
});
}
pub fn download(&self, file_id: i32) {
self.fetch_file(file_id, DOWNLOAD_PRIORITY, true);
}
pub fn download_quiet(&self, file_id: i32) {
self.fetch_file(file_id, QUIET_DOWNLOAD_PRIORITY, false);
}
fn fetch_file(&self, file_id: i32, priority: i32, report_errors: bool) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::download_file(file_id, priority, 0, 0, true, client_id).await;
let path = match result {
Ok(enums::File::File(f)) if f.local.is_downloading_completed => Some(f.local.path),
Ok(_) => None,
Err(e) => {
if report_errors {
let _ = tx.send(TgEvent::Error(e.message));
}
None
}
};
let _ = tx.send(TgEvent::Downloaded { file_id, path });
});
}
pub fn set_online(&self, online: bool) {
let value = enums::OptionValue::Boolean(types::OptionValueBoolean { value: online });
self.set_option("online", value);
}
pub fn enable_notifications(&self) {
let value = enums::OptionValue::Integer(types::OptionValueInteger {
value: NOTIFICATION_GROUPS,
});
self.set_option("notification_group_count_max", value);
}
fn set_option(&self, name: &str, value: enums::OptionValue) {
self.spawn(functions::set_option(
name.into(),
Some(value),
self.client_id,
));
}
pub fn view_messages(&self, chat_id: i64, topic: Option<i32>, message_ids: Vec<i64>) {
let source = topic.map(|_| enums::MessageSource::ForumTopicHistory);
self.spawn(functions::view_messages(
chat_id,
message_ids,
source,
true,
self.client_id,
));
}
pub fn forum_topics(&self, chat_id: i64, request: u32, from: Offset) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_forum_topics(
chat_id,
String::new(),
from.date,
from.message_id,
from.topic_id,
topics::PAGE,
client_id,
)
.await;
let page = match result {
Ok(enums::ForumTopics::ForumTopics(page)) => Some(page),
Err(e) => {
let _ = tx.send(TgEvent::Error(e.message));
None
}
};
let _ = tx.send(TgEvent::Topics {
chat_id,
request,
page,
});
});
}
pub fn forum_topic(&self, chat_id: i64, topic_id: i32) {
let tx = self.tx.clone();
let client_id = self.client_id;
tokio::spawn(async move {
let result = functions::get_forum_topic(chat_id, topic_id, client_id).await;
let topic = result
.ok()
.map(|enums::ForumTopic::ForumTopic(t)| Box::new(t));
let _ = tx.send(TgEvent::Topic {
chat_id,
topic_id,
topic,
});
});
}
pub fn close(&self) {
let client_id = self.client_id;
self.spawn(async move {
let offline = enums::OptionValue::Boolean(types::OptionValueBoolean { value: false });
let _ = functions::set_option("online".into(), Some(offline), client_id).await;
functions::close(client_id).await
});
}
pub fn log_out(&self) {
self.spawn(functions::log_out(self.client_id));
}
pub fn reopen(&mut self) {
self.client_id = tdlib_rs::create_client();
CLIENT.store(self.client_id, Ordering::Relaxed);
self.tx.client_id = self.client_id;
self.spawn(functions::get_option("version".into(), self.client_id));
}
fn spawn<T: Send + 'static>(
&self,
request: impl Future<Output = Result<T, types::Error>> + Send + 'static,
) {
let tx = self.tx.clone();
tokio::spawn(async move {
if let Err(e) = request.await {
let _ = tx.send(TgEvent::Error(e.message));
}
});
}
}
pub enum Upload {
Photo {
path: String,
width: i32,
height: i32,
},
File { path: String },
}
impl Upload {
fn content(self, caption: Option<types::FormattedText>) -> enums::InputMessageContent {
let local = |path| enums::InputFile::Local(types::InputFileLocal { path });
match self {
Upload::Photo {
path,
width,
height,
} => enums::InputMessageContent::InputMessagePhoto(types::InputMessagePhoto {
photo: local(path),
thumbnail: None,
added_sticker_file_ids: Vec::new(),
width,
height,
caption,
show_caption_above_media: false,
self_destruct_type: None,
has_spoiler: false,
}),
Upload::File { path } => {
enums::InputMessageContent::InputMessageDocument(types::InputMessageDocument {
document: local(path),
thumbnail: None,
disable_content_type_detection: false,
caption,
})
}
}
}
}
async fn search_page(
chat_id: i64,
topic: Option<i32>,
ask: &Query,
mut from: i64,
limit: i32,
client_id: i32,
) -> Result<Found, String> {
let sender = match &ask.from {
Some(Who::Sender(Sender::User(user_id))) => Some(user_sender(*user_id)),
Some(Who::Sender(Sender::Chat(chat_id))) => Some(chat_sender(*chat_id)),
Some(Who::Username(name)) => match public_chat(name, client_id).await? {
id if id > 0 => Some(user_sender(id)),
id => Some(chat_sender(id)),
},
Some(Who::Me | Who::Name(_)) | None => None,
};
let mut offset = 0;
if from == 0
&& let Some(before) = ask.before
{
match functions::get_chat_message_by_date(chat_id, before, client_id).await {
Ok(enums::Message::Message(m)) => (from, offset) = (m.id, -1),
Err(e) if e.code == 404 => {
return Ok(Found {
ids: Vec::new(),
total: 0,
next_from: 0,
next_offset: String::new(),
});
}
Err(e) => return Err(e.message),
}
}
let enums::FoundChatMessages::FoundChatMessages(found) = functions::search_chat_messages(
chat_id,
forum_topic(topic),
ask.words.clone(),
sender,
from,
offset,
limit,
ask.has.map(Has::tdlib),
client_id,
)
.await
.map_err(|e| e.message)?;
let mut page = Found {
ids: Vec::new(),
total: found.total_count,
next_from: found.next_from_message_id,
next_offset: String::new(),
};
page.keep_dates(ask, &found.messages);
Ok(page)
}
async fn secret_search_page(
chat_id: i64,
ask: &Query,
offset: String,
limit: i32,
client_id: i32,
) -> Result<Found, String> {
let enums::FoundMessages::FoundMessages(found) = functions::search_secret_messages(
chat_id,
ask.words.clone(),
offset,
limit,
ask.has.map(Has::tdlib),
client_id,
)
.await
.map_err(|e| e.message)?;
let mut page = Found {
ids: Vec::new(),
total: found.total_count,
next_from: 0,
next_offset: found.next_offset,
};
page.keep_dates(ask, &found.messages);
Ok(page)
}
impl Found {
fn keep_dates(&mut self, ask: &Query, messages: &[types::Message]) {
for m in messages {
if ask.after.is_some_and(|after| m.date < after) {
self.next_from = 0;
self.next_offset.clear();
break;
}
if ask.before.is_none_or(|before| m.date < before) {
self.ids.push(m.id);
}
}
if ask.before.is_some() || ask.after.is_some() {
self.total = -1;
}
}
}
fn topic_of(message: &types::Message, general: i32) -> i32 {
match &message.topic_id {
Some(enums::MessageTopic::Forum(t)) => t.forum_topic_id,
_ => general,
}
}
fn forum_topic(topic: Option<i32>) -> Option<enums::MessageTopic> {
topic.map(|forum_topic_id| {
enums::MessageTopic::Forum(types::MessageTopicForum { forum_topic_id })
})
}
fn user_sender(user_id: i64) -> enums::MessageSender {
enums::MessageSender::User(types::MessageSenderUser { user_id })
}
fn chat_sender(chat_id: i64) -> enums::MessageSender {
enums::MessageSender::Chat(types::MessageSenderChat { chat_id })
}
async fn public_chat(username: &str, client_id: i32) -> Result<i64, String> {
match functions::search_public_chat(username.to_string(), client_id).await {
Ok(enums::Chat::Chat(chat)) => Ok(chat.id),
Err(e) if e.code == 400 => Err(format!(
"Nobody on Telegram is called @{}",
crate::text::clean(username)
)),
Err(e) => Err(e.message),
}
}
fn reply_to_message(message_id: i64) -> enums::InputMessageReplyTo {
enums::InputMessageReplyTo::Message(types::InputMessageReplyToMessage {
message_id,
quote: None,
checklist_task_id: 0,
})
}
pub fn plain(text: String) -> types::FormattedText {
types::FormattedText {
text,
entities: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quiet() {
let request = json!({ "@type": "setLogVerbosityLevel", "new_verbosity_level": 1 });
execute(&request).unwrap();
}
fn entities(text: &types::FormattedText) -> Vec<(i32, i32, String)> {
text.entities
.iter()
.map(|e| {
let kind = format!("{:?}", e.r#type);
(e.offset, e.length, kind)
})
.collect()
}
#[test]
fn markdown_in_the_composer_is_sent_as_formatting() {
quiet();
let sent =
markdown("**bold** __it__ ~~gone~~ ||secret|| `code` [site](https://x.dev)".into());
assert_eq!(sent.text, "bold it gone secret code site");
assert_eq!(
entities(&sent),
[
(0, 4, "Bold".into()),
(5, 2, "Italic".into()),
(8, 4, "Strikethrough".into()),
(13, 6, "Spoiler".into()),
(20, 4, "Code".into()),
(
25,
4,
"TextUrl(TextEntityTypeTextUrl { url: \"https://x.dev/\" })".into()
),
]
);
}
#[test]
fn a_markdown_link_to_a_user_mentions_them() {
quiet();
let sent = markdown("hi [Bob Smith](tg://user?id=123)".into());
assert_eq!(sent.text, "hi Bob Smith");
assert_eq!(
entities(&sent),
[(
3,
9,
"MentionName(TextEntityTypeMentionName { user_id: 123 })".into()
)]
);
}
#[test]
fn single_stars_and_underscores_stay_as_typed() {
quiet();
let sent = markdown("2*3*4 = 24, snake_case_name, a ** b".into());
assert_eq!(sent.text, "2*3*4 = 24, snake_case_name, a ** b");
assert!(sent.entities.is_empty(), "{:?}", sent.entities);
}
#[test]
fn edits_start_from_the_message_as_markdown() {
quiet();
let entity = |offset, length, r#type| types::TextEntity {
offset,
length,
r#type,
};
let bold = types::FormattedText {
text: "bold and plain".into(),
entities: vec![entity(0, 4, enums::TextEntityType::Bold)],
};
let edit = to_markdown(&bold).unwrap();
assert_eq!(edit.markdown, "**bold** and plain");
assert!(!edit.loses);
assert_eq!(markdown(edit.markdown), bold);
let underlined = types::FormattedText {
text: "under".into(),
entities: vec![entity(0, 5, enums::TextEntityType::Underline)],
};
assert!(
to_markdown(&underlined).unwrap().loses,
"Markdown has no underline"
);
let mention = types::FormattedText {
text: "hi Bob".into(),
entities: vec![entity(
3,
3,
enums::TextEntityType::MentionName(types::TextEntityTypeMentionName {
user_id: 123,
}),
)],
};
let edit = to_markdown(&mention).unwrap();
assert_eq!(edit.markdown, "hi [Bob](tg://user?id=123)");
assert!(!edit.loses);
assert_eq!(markdown(edit.markdown), mention);
}
#[test]
fn openssl_is_told_not_to_read_a_config_file() {
super::no_openssl_config().unwrap();
super::no_openssl_config().unwrap();
}
}