From e79946b4e4898418976d4a943975589229209aec Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 27 Jun 2024 16:18:41 +0800 Subject: [PATCH] telegram bot ui settings and code sending --- flutter/lib/common/widgets/dialog.dart | 146 +++++++++++++----- .../lib/desktop/pages/connection_page.dart | 2 +- .../desktop/pages/desktop_setting_page.dart | 41 ++++- src/auth_2fa.rs | 52 ++++--- src/flutter_ffi.rs | 8 + src/lang/en.rs | 2 + src/lang/template.rs | 3 + src/server/connection.rs | 30 +++- src/ui_interface.rs | 14 ++ 9 files changed, 236 insertions(+), 62 deletions(-) diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index 1b4839dfc..9cfa65b1b 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; @@ -218,50 +219,53 @@ void changeWhiteList({Function()? callback}) async { ), actions: [ dialogButton("Cancel", onPressed: close, isOutline: true), - if (!isOptFixed)dialogButton("Clear", onPressed: () async { - await bind.mainSetOption( - key: kOptionWhitelist, value: defaultOptionWhitelist); - callback?.call(); - close(); - }, isOutline: true), - if (!isOptFixed) dialogButton( - "OK", - onPressed: () async { - setState(() { - msg = ""; - isInProgress = true; - }); - newWhiteListField = controller.text.trim(); - var newWhiteList = ""; - if (newWhiteListField.isEmpty) { - // pass - } else { - final ips = newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); - // test ip - final ipMatch = RegExp( - r"^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$"); - final ipv6Match = RegExp( - r"^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$"); - for (final ip in ips) { - if (!ipMatch.hasMatch(ip) && !ipv6Match.hasMatch(ip)) { - msg = "${translate("Invalid IP")} $ip"; - setState(() { - isInProgress = false; - }); - return; - } - } - newWhiteList = ips.join(','); - } - if (newWhiteList.trim().isEmpty) { - newWhiteList = defaultOptionWhitelist; - } + if (!isOptFixed) + dialogButton("Clear", onPressed: () async { await bind.mainSetOption( - key: kOptionWhitelist, value: newWhiteList); + key: kOptionWhitelist, value: defaultOptionWhitelist); callback?.call(); close(); - }, - ), + }, isOutline: true), + if (!isOptFixed) + dialogButton( + "OK", + onPressed: () async { + setState(() { + msg = ""; + isInProgress = true; + }); + newWhiteListField = controller.text.trim(); + var newWhiteList = ""; + if (newWhiteListField.isEmpty) { + // pass + } else { + final ips = + newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); + // test ip + final ipMatch = RegExp( + r"^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$"); + final ipv6Match = RegExp( + r"^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$"); + for (final ip in ips) { + if (!ipMatch.hasMatch(ip) && !ipv6Match.hasMatch(ip)) { + msg = "${translate("Invalid IP")} $ip"; + setState(() { + isInProgress = false; + }); + return; + } + } + newWhiteList = ips.join(','); + } + if (newWhiteList.trim().isEmpty) { + newWhiteList = defaultOptionWhitelist; + } + await bind.mainSetOption( + key: kOptionWhitelist, value: newWhiteList); + callback?.call(); + close(); + }, + ), ], onCancel: close, ); @@ -1762,6 +1766,66 @@ void renameDialog( }); } +void changeBot({Function()? callback}) async { + if (bind.mainHasValidBotSync()) { + await bind.mainSetOption(key: "bot", value: ""); + callback?.call(); + return; + } + String errorText = ''; + bool loading = false; + final controller = TextEditingController(); + gFFI.dialogManager.show((setState, close, context) { + onVerify() async { + final token = controller.text.trim(); + if (token == "") return; + loading = true; + errorText = ''; + setState(() {}); + final error = await bind.mainVerifyBot(token: token); + if (error == "") { + callback?.call(); + close(); + } else { + errorText = translate(error); + loading = false; + setState(() {}); + } + } + + final codeField = TextField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + hintText: translate('Token'), // 使用hintText设置占位符文本 + ), + ); + + return CustomAlertDialog( + title: Text(translate("Telegram bot")), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(translate("enable-bot-desc"), + style: TextStyle(fontSize: 12)) + .marginOnly(bottom: 12), + Row(children: [Expanded(child: codeField)]), + if (errorText != '') + Text(errorText, style: TextStyle(color: Colors.red)) + .marginOnly(top: 12), + ], + ), + actions: [ + dialogButton("Cancel", onPressed: close, isOutline: true), + loading + ? CircularProgressIndicator() + : dialogButton("OK", onPressed: onVerify), + ], + onCancel: close, + ); + }); +} + void change2fa({Function()? callback}) async { if (bind.mainHasValid2FaSync()) { await bind.mainSetOption(key: "2fa", value: ""); diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index d1ecc29f8..797cafcbe 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -340,7 +340,7 @@ class _ConnectionPageState extends State ?.merge(TextStyle(height: 1)), ).marginOnly(right: 4), Tooltip( - waitDuration: Duration(milliseconds: 0), + waitDuration: Duration(milliseconds: 300), message: translate("id_input_tip"), child: Icon( Icons.help_outline_outlined, diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 81717de3b..d3b6add8a 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -679,6 +679,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { // Simple temp wrapper for PR check tmpWrapper() { RxBool has2fa = bind.mainHasValid2FaSync().obs; + RxBool hasBot = bind.mainHasValidBotSync().obs; update() async { has2fa.value = bind.mainHasValid2FaSync(); } @@ -687,7 +688,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { change2fa(callback: update); } - return GestureDetector( + final tfa = GestureDetector( child: InkWell( child: Obx(() => Row( children: [ @@ -708,6 +709,44 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { onChanged(!has2fa.value); }, ).marginOnly(left: _kCheckBoxLeftMargin); + if (!has2fa.value) { + return tfa; + } + updateBot() async { + hasBot.value = bind.mainHasValidBotSync(); + } + + onChangedBot(bool? checked) async { + changeBot(callback: updateBot); + } + + final bot = GestureDetector( + child: Tooltip( + waitDuration: Duration(milliseconds: 300), + message: translate("enable-bot-tip"), + child: InkWell( + child: Obx(() => Row( + children: [ + Checkbox( + value: hasBot.value, + onChanged: enabled ? onChangedBot : null) + .marginOnly(right: 5), + Expanded( + child: Text( + translate('Telegram bot'), + style: TextStyle( + color: disabledTextColor(context, enabled)), + )) + ], + ))), + ), + onTap: () { + onChangedBot(!hasBot.value); + }, + ).marginOnly(left: _kCheckBoxLeftMargin + 30); + return Column( + children: [tfa, bot], + ); } return tmpWrapper(); diff --git a/src/auth_2fa.rs b/src/auth_2fa.rs index f46bc71e6..161853722 100644 --- a/src/auth_2fa.rs +++ b/src/auth_2fa.rs @@ -4,7 +4,7 @@ use hbb_common::{ config::Config, get_time, password_security::{decrypt_vec_or_original, encrypt_vec_or_original}, - ResultType, + tokio, ResultType, }; use serde_derive::{Deserialize, Serialize}; use std::sync::Mutex; @@ -133,46 +133,61 @@ impl TelegramBot { fn save(&self) -> ResultType<()> { let s = self.into_string()?; #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::ipc::set_option("telegram_bot", &s); + crate::ipc::set_option("bot", &s); #[cfg(any(target_os = "android", target_os = "ios"))] - Config::set_option("telegram_bot".to_owned(), s); + Config::set_option("bot".to_owned(), s); Ok(()) } - fn get() -> ResultType { - let data = Config::get_option("telegram_bot"); + pub fn get() -> ResultType> { + let data = Config::get_option("bot"); + if data.is_empty() { + return Ok(None); + } let mut bot = serde_json::from_str::(&data)?; let (token, success, _) = decrypt_vec_or_original(&bot.token, "00"); if success { bot.token_str = String::from_utf8(token)?; - return Ok(bot); + return Ok(Some(bot)); } bail!("decrypt_vec_or_original telegram bot token failed") } } // https://gist.github.com/dideler/85de4d64f66c1966788c1b2304b9caf1 -pub async fn send_2fa_code_to_telegram(code: &str) -> ResultType<()> { - let bot = TelegramBot::get()?; +pub async fn send_2fa_code_to_telegram(text: &str, bot: TelegramBot) -> ResultType<()> { let url = format!("https://api.telegram.org/bot{}/sendMessage", bot.token_str); - let params = serde_json::json!({"chat_id": bot.chat_id, "text": code}); + let params = serde_json::json!({"chat_id": bot.chat_id, "text": text}); crate::post_request(url, params.to_string(), "").await?; Ok(()) } +#[tokio::main(flavor = "current_thread")] pub async fn get_chatid_telegram(bot_token: &str) -> ResultType> { - // send a message to the bot first please, otherwise the chat_id will be empty let url = format!("https://api.telegram.org/bot{}/getUpdates", bot_token); let resp = crate::post_request(url, "".to_owned(), "") .await .map_err(|e| anyhow!(e))?; - let res = serde_json::from_str::(&resp) - .map(|x| { - let chat_id = x["result"][0]["message"]["chat"]["id"].as_str(); - chat_id.map(|x| x.to_owned()) - }) - .map_err(|e| anyhow!(e)); - if let Ok(Some(chat_id)) = res.as_ref() { + let value = serde_json::from_str::(&resp).map_err(|e| anyhow!(e))?; + + // Check for an error_code in the response + if let Some(error_code) = value.get("error_code").and_then(|code| code.as_i64()) { + // If there's an error_code, try to use the description for the error message + let description = value["description"] + .as_str() + .unwrap_or("Unknown error occurred"); + return Err(anyhow!( + "Telegram API error: {} (error_code: {})", + description, + error_code + )); + } + + let chat_id = value["result"][0]["message"]["chat"]["id"] + .as_str() + .map(|x| x.to_owned()); + + if let Some(chat_id) = chat_id.as_ref() { let bot = TelegramBot { token_str: bot_token.to_owned(), chat_id: chat_id.to_owned(), @@ -180,5 +195,6 @@ pub async fn get_chatid_telegram(bot_token: &str) -> ResultType> }; bot.save()?; } - res + + Ok(chat_id) } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index b8ae1abee..5371b225e 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2178,6 +2178,14 @@ pub fn main_has_valid_2fa_sync() -> SyncReturn { SyncReturn(has_valid_2fa()) } +pub fn main_verify_bot(token: String) -> String { + verify_bot(token) +} + +pub fn main_has_valid_bot_sync() -> SyncReturn { + SyncReturn(has_valid_bot()) +} + pub fn main_get_hard_option(key: String) -> SyncReturn { SyncReturn(get_hard_option(key)) } diff --git a/src/lang/en.rs b/src/lang/en.rs index d8aa388ec..46a754b60 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -230,5 +230,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android_new_voice_call_tip", "A new voice call request was received. If you accept, the audio will switch to voice communication."), ("texture_render_tip", "Use texture rendering to make the pictures smoother. You could try disabling this option if you encounter rendering issues."), ("floating_window_tip", "It helps to keep RustDesk background service"), + ("enable-bot-tip", "If you enable this feature, you can receive the 2FA code from your bot. It can also function as a connection notification."), + ("enable-bot-desc", "1, Open a chat with @BotFather.\n2, Send the command \"/newbot\". You will receive a token after completing this step.\n3, Start a chat with your newly created bot. Send a message like \"hello\" to activate it.\n"), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 3d84f6624..d7744cc36 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -627,5 +627,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Volume up", ""), ("Volume down", ""), ("Power", ""), + ("Telegram bot", ""), + ("enable-bot-tip", ""), + ("enable-bot-desc", ""), ].iter().cloned().collect(); } diff --git a/src/server/connection.rs b/src/server/connection.rs index 2c649f0a9..2441c775f 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -401,7 +401,8 @@ impl Connection { #[cfg(target_os = "android")] start_channel(rx_to_cm, tx_from_cm); #[cfg(target_os = "android")] - conn.send_permission(Permission::Keyboard, conn.keyboard).await; + conn.send_permission(Permission::Keyboard, conn.keyboard) + .await; #[cfg(not(target_os = "android"))] if !conn.keyboard { conn.send_permission(Permission::Keyboard, false).await; @@ -1079,6 +1080,33 @@ impl Connection { return; } if self.require_2fa.is_some() && !self.is_recent_session(true) && !self.from_switch { + self.require_2fa.as_ref().map(|totp| { + let bot = crate::auth_2fa::TelegramBot::get(); + let bot = match bot { + Ok(Some(bot)) => bot, + Err(err) => { + log::error!("Failed to get telegram bot: {}", err); + return; + } + _ => return, + }; + let code = totp.generate_current(); + if let Ok(code) = code { + let text = format!( + "2FA code: {}\n\nA new connection has been established to your device with ID {}. The source IP address is {}.", + code, + Config::get_id(), + self.ip, + ); + tokio::spawn(async move { + if let Err(err) = + crate::auth_2fa::send_2fa_code_to_telegram(&text, bot).await + { + log::error!("Failed to send 2fa code to telegram bot: {}", err); + } + }); + } + }); self.send_login_error(crate::client::REQUIRE_2FA).await; return; } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 6c95d4c4a..c87f12b4d 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -1392,6 +1392,20 @@ pub fn verify2fa(code: String) -> bool { res } +pub fn has_valid_bot() -> bool { + crate::auth_2fa::TelegramBot::get().map_or(false, |bot| bot.is_some()) +} + +pub fn verify_bot(token: String) -> String { + match crate::auth_2fa::get_chatid_telegram(&token) { + Err(err) => err.to_string(), + Ok(None) => { + "To activate the bot, simply send a message like \"hello\" to its chat.".to_owned() + } + _ => "".to_owned(), + } +} + pub fn check_hwcodec() { #[cfg(feature = "hwcodec")] #[cfg(any(target_os = "windows", target_os = "linux"))]