| #![recursion_limit = "256"] |
| |
| |
| |
| |
| |
|
|
| extern crate deltachat; |
|
|
| use std::borrow::Cow::{self, Borrowed, Owned}; |
|
|
| use anyhow::{bail, Error}; |
| use deltachat::chat::ChatId; |
| use deltachat::config; |
| use deltachat::context::*; |
| use deltachat::oauth2::*; |
| use deltachat::qr_code_generator::get_securejoin_qr_svg; |
| use deltachat::securejoin::*; |
| use deltachat::EventType; |
| use log::{error, info, warn}; |
| use nu_ansi_term::Color; |
| use rustyline::completion::{Completer, FilenameCompleter, Pair}; |
| use rustyline::error::ReadlineError; |
| use rustyline::highlight::{CmdKind as HighlightCmdKind, Highlighter, MatchingBracketHighlighter}; |
| use rustyline::hint::{Hinter, HistoryHinter}; |
| use rustyline::validate::Validator; |
| use rustyline::{ |
| Cmd, CompletionType, Config, Context as RustyContext, EditMode, Editor, Helper, KeyEvent, |
| }; |
| use tokio::fs; |
| use tokio::runtime::Handle; |
| use tracing_subscriber::EnvFilter; |
|
|
| mod cmdline; |
| use self::cmdline::*; |
|
|
| |
| fn receive_event(event: EventType) { |
| let yellow = Color::Yellow.normal(); |
| match event { |
| EventType::Info(msg) => { |
| |
| info!("{msg}"); |
| } |
| EventType::SmtpConnected(msg) => { |
| info!("[SMTP_CONNECTED] {msg}"); |
| } |
| EventType::ImapConnected(msg) => { |
| info!("[IMAP_CONNECTED] {msg}"); |
| } |
| EventType::SmtpMessageSent(msg) => { |
| info!("[SMTP_MESSAGE_SENT] {msg}"); |
| } |
| EventType::Warning(msg) => { |
| warn!("{msg}"); |
| } |
| EventType::Error(msg) => { |
| error!("{msg}"); |
| } |
| EventType::ErrorSelfNotInGroup(msg) => { |
| error!("[SELF_NOT_IN_GROUP] {msg}"); |
| } |
| EventType::MsgsChanged { chat_id, msg_id } => { |
| info!( |
| "{}", |
| yellow.paint(format!( |
| "Received MSGS_CHANGED(chat_id={chat_id}, msg_id={msg_id})", |
| )) |
| ); |
| } |
| EventType::ReactionsChanged { |
| chat_id, |
| msg_id, |
| contact_id, |
| } => { |
| info!( |
| "{}", |
| yellow.paint(format!( |
| "Received REACTIONS_CHANGED(chat_id={chat_id}, msg_id={msg_id}, contact_id={contact_id})" |
| )) |
| ); |
| } |
| EventType::ContactsChanged(_) => { |
| info!("{}", yellow.paint("Received CONTACTS_CHANGED()")); |
| } |
| EventType::LocationChanged(contact) => { |
| info!( |
| "{}", |
| yellow.paint(format!("Received LOCATION_CHANGED(contact={contact:?})")) |
| ); |
| } |
| EventType::ConfigureProgress { progress, comment } => { |
| if let Some(comment) = comment { |
| info!( |
| "{}", |
| yellow.paint(format!( |
| "Received CONFIGURE_PROGRESS({progress} ‰, {comment})" |
| )) |
| ); |
| } else { |
| info!( |
| "{}", |
| yellow.paint(format!("Received CONFIGURE_PROGRESS({progress} ‰)")) |
| ); |
| } |
| } |
| EventType::ImexProgress(progress) => { |
| info!( |
| "{}", |
| yellow.paint(format!("Received IMEX_PROGRESS({progress} ‰)")) |
| ); |
| } |
| EventType::ImexFileWritten(file) => { |
| info!( |
| "{}", |
| yellow.paint(format!("Received IMEX_FILE_WRITTEN({})", file.display())) |
| ); |
| } |
| EventType::ChatModified(chat) => { |
| info!( |
| "{}", |
| yellow.paint(format!("Received CHAT_MODIFIED({chat})")) |
| ); |
| } |
| _ => { |
| info!("Received {event:?}"); |
| } |
| } |
| } |
|
|
| |
|
|
| struct DcHelper { |
| completer: FilenameCompleter, |
| highlighter: MatchingBracketHighlighter, |
| hinter: HistoryHinter, |
| } |
|
|
| impl Completer for DcHelper { |
| type Candidate = Pair; |
|
|
| fn complete( |
| &self, |
| line: &str, |
| pos: usize, |
| ctx: &RustyContext<'_>, |
| ) -> Result<(usize, Vec<Pair>), ReadlineError> { |
| self.completer.complete(line, pos, ctx) |
| } |
| } |
|
|
| const IMEX_COMMANDS: [&str; 13] = [ |
| "initiate-key-transfer", |
| "get-setupcodebegin", |
| "continue-key-transfer", |
| "has-backup", |
| "export-backup", |
| "import-backup", |
| "send-backup", |
| "receive-backup", |
| "export-keys", |
| "import-keys", |
| "poke", |
| "reset", |
| "stop", |
| ]; |
|
|
| const DB_COMMANDS: [&str; 11] = [ |
| "info", |
| "set", |
| "get", |
| "oauth2", |
| "configure", |
| "connect", |
| "disconnect", |
| "fetch", |
| "connectivity", |
| "maybenetwork", |
| "housekeeping", |
| ]; |
|
|
| const CHAT_COMMANDS: [&str; 39] = [ |
| "listchats", |
| "listarchived", |
| "start-realtime", |
| "send-realtime", |
| "chat", |
| "createchat", |
| "creategroup", |
| "createbroadcast", |
| "createprotected", |
| "addmember", |
| "removemember", |
| "groupname", |
| "groupimage", |
| "chatinfo", |
| "sendlocations", |
| "setlocation", |
| "dellocations", |
| "getlocations", |
| "send", |
| "send-sync", |
| "sendempty", |
| "sendimage", |
| "sendsticker", |
| "sendfile", |
| "sendhtml", |
| "sendsyncmsg", |
| "sendupdate", |
| "draft", |
| "devicemsg", |
| "listmedia", |
| "archive", |
| "unarchive", |
| "pin", |
| "unpin", |
| "mute", |
| "unmute", |
| "delchat", |
| "accept", |
| "blockchat", |
| ]; |
| const MESSAGE_COMMANDS: [&str; 10] = [ |
| "listmsgs", |
| "msginfo", |
| "download", |
| "html", |
| "listfresh", |
| "forward", |
| "resend", |
| "markseen", |
| "delmsg", |
| "react", |
| ]; |
| const CONTACT_COMMANDS: [&str; 9] = [ |
| "listcontacts", |
| "addcontact", |
| "contactinfo", |
| "delcontact", |
| "block", |
| "unblock", |
| "listblocked", |
| "import-vcard", |
| "make-vcard", |
| ]; |
| const MISC_COMMANDS: [&str; 14] = [ |
| "getqr", |
| "getqrsvg", |
| "getbadqr", |
| "checkqr", |
| "joinqr", |
| "setqr", |
| "createqrsvg", |
| "providerinfo", |
| "fileinfo", |
| "estimatedeletion", |
| "clear", |
| "exit", |
| "quit", |
| "help", |
| ]; |
|
|
| impl Hinter for DcHelper { |
| type Hint = String; |
|
|
| fn hint(&self, line: &str, pos: usize, ctx: &RustyContext<'_>) -> Option<Self::Hint> { |
| if !line.is_empty() { |
| for &cmds in &[ |
| &IMEX_COMMANDS[..], |
| &DB_COMMANDS[..], |
| &CHAT_COMMANDS[..], |
| &MESSAGE_COMMANDS[..], |
| &CONTACT_COMMANDS[..], |
| &MISC_COMMANDS[..], |
| ] { |
| if let Some(entry) = cmds.iter().find(|el| el.starts_with(&line[..pos])) { |
| if *entry != line && *entry != &line[..pos] { |
| return Some(entry[pos..].to_owned()); |
| } |
| } |
| } |
| } |
| self.hinter.hint(line, pos, ctx) |
| } |
| } |
|
|
| static COLORED_PROMPT: &str = "\x1b[1;32m> \x1b[0m"; |
|
|
| impl Highlighter for DcHelper { |
| fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&self, prompt: &'p str, default: bool) -> Cow<'b, str> { |
| if default { |
| Borrowed(COLORED_PROMPT) |
| } else { |
| Borrowed(prompt) |
| } |
| } |
|
|
| fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { |
| Owned("\x1b[1m".to_owned() + hint + "\x1b[m") |
| } |
|
|
| fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { |
| self.highlighter.highlight(line, pos) |
| } |
|
|
| fn highlight_char(&self, line: &str, pos: usize, kind: HighlightCmdKind) -> bool { |
| self.highlighter.highlight_char(line, pos, kind) |
| } |
| } |
|
|
| impl Helper for DcHelper {} |
| impl Validator for DcHelper {} |
|
|
| async fn start(args: Vec<String>) -> Result<(), Error> { |
| if args.len() < 2 { |
| eprintln!("Error: Bad arguments, expected [db-name]."); |
| bail!("No db-name specified"); |
| } |
| let context = ContextBuilder::new(args[1].clone().into()) |
| .with_id(1) |
| .open() |
| .await?; |
|
|
| let events = context.get_event_emitter(); |
| tokio::task::spawn(async move { |
| while let Some(event) = events.recv().await { |
| receive_event(event.typ); |
| } |
| }); |
|
|
| println!("Chatmail is awaiting your commands."); |
|
|
| let config = Config::builder() |
| .history_ignore_space(true) |
| .completion_type(CompletionType::List) |
| .edit_mode(EditMode::Emacs) |
| .build(); |
| let mut selected_chat = ChatId::default(); |
|
|
| let ctx = context.clone(); |
| let input_loop = tokio::task::spawn_blocking(move || { |
| let h = DcHelper { |
| completer: FilenameCompleter::new(), |
| highlighter: MatchingBracketHighlighter::new(), |
| hinter: HistoryHinter {}, |
| }; |
| let mut rl = Editor::with_config(config)?; |
| rl.set_helper(Some(h)); |
| rl.bind_sequence(KeyEvent::alt('N'), Cmd::HistorySearchForward); |
| rl.bind_sequence(KeyEvent::alt('P'), Cmd::HistorySearchBackward); |
| if rl.load_history(".dc-history.txt").is_err() { |
| println!("No previous history."); |
| } |
|
|
| loop { |
| let p = "> "; |
| let readline = rl.readline(p); |
|
|
| match readline { |
| Ok(line) => { |
| |
| rl.add_history_entry(line.as_str())?; |
| let should_continue = Handle::current().block_on(async { |
| match handle_cmd(line.trim(), ctx.clone(), &mut selected_chat).await { |
| Ok(ExitResult::Continue) => true, |
| Ok(ExitResult::Exit) => { |
| println!("Exiting ..."); |
| false |
| } |
| Err(err) => { |
| eprintln!("Error: {err:#}"); |
| true |
| } |
| } |
| }); |
|
|
| if !should_continue { |
| break; |
| } |
| } |
| Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { |
| println!("Exiting..."); |
| break; |
| } |
| Err(err) => { |
| eprintln!("Error: {err:#}"); |
| break; |
| } |
| } |
| } |
|
|
| rl.save_history(".dc-history.txt")?; |
| println!("history saved"); |
| Ok::<_, Error>(()) |
| }); |
|
|
| context.stop_io().await; |
| input_loop.await??; |
|
|
| Ok(()) |
| } |
|
|
| #[derive(Debug)] |
| enum ExitResult { |
| Continue, |
| Exit, |
| } |
|
|
| async fn handle_cmd( |
| line: &str, |
| ctx: Context, |
| selected_chat: &mut ChatId, |
| ) -> Result<ExitResult, Error> { |
| let mut args = line.splitn(2, ' '); |
| let arg0 = args.next().unwrap_or_default(); |
| let arg1 = args.next().unwrap_or_default(); |
|
|
| match arg0 { |
| "connect" => { |
| ctx.start_io().await; |
| } |
| "disconnect" => { |
| ctx.stop_io().await; |
| } |
| "fetch" => { |
| ctx.background_fetch().await?; |
| } |
| "configure" => { |
| ctx.configure().await?; |
| } |
| "oauth2" => { |
| if let Some(addr) = ctx.get_config(config::Config::Addr).await? { |
| if let Some(oauth2_url) = |
| get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await? |
| { |
| println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{oauth2_url}"); |
| } else { |
| println!("OAuth2 not available for {}.", &addr); |
| } |
| } else { |
| println!("oauth2: set addr first."); |
| } |
| } |
| "clear" => { |
| println!("\n\n\n"); |
| print!("\x1b[1;1H\x1b[2J"); |
| } |
| "getqr" | "getbadqr" => { |
| ctx.start_io().await; |
| let group = arg1.parse::<u32>().ok().map(ChatId::new); |
| let mut qr = get_securejoin_qr(&ctx, group).await?; |
| if !qr.is_empty() { |
| if arg0 == "getbadqr" && qr.len() > 40 { |
| qr.replace_range(12..22, "0000000000") |
| } |
| println!("{qr}"); |
| qr2term::print_qr(qr.as_str())?; |
| } |
| } |
| "getqrsvg" => { |
| ctx.start_io().await; |
| let group = arg1.parse::<u32>().ok().map(ChatId::new); |
| let file = dirs::home_dir().unwrap_or_default().join("qr.svg"); |
| match get_securejoin_qr_svg(&ctx, group).await { |
| Ok(svg) => { |
| fs::write(&file, svg).await?; |
| println!("QR code svg written to: {file:#?}"); |
| } |
| Err(err) => { |
| bail!("Failed to get QR code svg: {err}"); |
| } |
| } |
| } |
| "joinqr" => { |
| ctx.start_io().await; |
| if !arg0.is_empty() { |
| join_securejoin(&ctx, arg1).await?; |
| } |
| } |
| "exit" | "quit" => return Ok(ExitResult::Exit), |
| _ => cmdline(ctx.clone(), line, selected_chat).await?, |
| } |
|
|
| Ok(ExitResult::Continue) |
| } |
|
|
| #[tokio::main] |
| async fn main() -> Result<(), Error> { |
| tracing_subscriber::fmt() |
| .with_env_filter( |
| EnvFilter::from_default_env().add_directive("deltachat_repl=info".parse()?), |
| ) |
| .init(); |
|
|
| let args = std::env::args().collect(); |
| start(args).await?; |
|
|
| Ok(()) |
| } |
|
|