Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'
git-subtree-dir: core git-subtree-mainline:a07f3dcd23git-subtree-split:b0dfc4146c
This commit is contained in:
4
core/kpcli/.gitignore
vendored
Normal file
4
core/kpcli/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.env.*
|
||||
|
||||
|
||||
38
core/kpcli/Cargo.toml
Normal file
38
core/kpcli/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "kpcli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.93"
|
||||
clap = { version = "4.5.20", features = ["derive"] }
|
||||
dotenv = "0.15.0"
|
||||
env_logger = "0.11.8"
|
||||
futures-util = "0.3.31"
|
||||
kordophone = { path = "../kordophone" }
|
||||
kordophone-db = { path = "../kordophone-db" }
|
||||
log = "0.4.22"
|
||||
pretty = { version = "0.12.3", features = ["termcolor"] }
|
||||
prettytable = "0.10.0"
|
||||
serde_json = "1.0"
|
||||
time = "0.3.37"
|
||||
tokio = "1.41.1"
|
||||
async-trait = "0.1.80"
|
||||
|
||||
# D-Bus dependencies only on Linux
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dbus = "0.9.7"
|
||||
dbus-tree = "0.9.2"
|
||||
|
||||
# D-Bus codegen only on Linux
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
dbus-codegen = "0.10.0"
|
||||
|
||||
# XPC (libxpc) interface only on macOS
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block = "0.1.6"
|
||||
futures = "0.3.4"
|
||||
xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" }
|
||||
xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" }
|
||||
27
core/kpcli/build.rs
Normal file
27
core/kpcli/build.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml";
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
// No D-Bus codegen on non-Linux platforms
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs");
|
||||
|
||||
let opts = dbus_codegen::GenOpts {
|
||||
connectiontype: dbus_codegen::ConnectionType::Blocking,
|
||||
methodtype: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface");
|
||||
|
||||
let output =
|
||||
dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface");
|
||||
|
||||
std::fs::write(out_path, output).expect("Error writing client dbus code");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
||||
}
|
||||
178
core/kpcli/src/client/mod.rs
Normal file
178
core/kpcli/src/client/mod.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use kordophone::api::event_socket::{EventSocket, SocketEvent, SocketUpdate};
|
||||
use kordophone::api::http_client::Credentials;
|
||||
use kordophone::api::http_client::HTTPAPIClient;
|
||||
use kordophone::api::InMemoryAuthenticationStore;
|
||||
use kordophone::APIInterface;
|
||||
|
||||
use crate::printers::{ConversationPrinter, MessagePrinter};
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use kordophone::model::event::EventData;
|
||||
use kordophone::model::outgoing_message::OutgoingMessage;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
||||
pub fn make_api_client_from_env() -> HTTPAPIClient<InMemoryAuthenticationStore> {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// read from env
|
||||
let base_url = std::env::var("KORDOPHONE_API_URL").expect("KORDOPHONE_API_URL must be set");
|
||||
|
||||
let credentials = Credentials {
|
||||
username: std::env::var("KORDOPHONE_USERNAME").expect("KORDOPHONE_USERNAME must be set"),
|
||||
|
||||
password: std::env::var("KORDOPHONE_PASSWORD").expect("KORDOPHONE_PASSWORD must be set"),
|
||||
};
|
||||
|
||||
HTTPAPIClient::new(
|
||||
base_url.parse().unwrap(),
|
||||
InMemoryAuthenticationStore::new(Some(credentials)),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Prints all known conversations on the server.
|
||||
Conversations,
|
||||
|
||||
/// Prints all messages in a conversation.
|
||||
Messages { conversation_id: String },
|
||||
|
||||
/// Prints the server Kordophone version.
|
||||
Version,
|
||||
|
||||
/// Prints all events from the server.
|
||||
Events,
|
||||
|
||||
/// Prints all raw updates from the server.
|
||||
RawUpdates,
|
||||
|
||||
/// Sends a message to the server.
|
||||
SendMessage {
|
||||
conversation_id: String,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Marks a conversation as read.
|
||||
Mark { conversation_id: String },
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub async fn run(cmd: Commands) -> Result<()> {
|
||||
let mut client = ClientCli::new();
|
||||
match cmd {
|
||||
Commands::Version => client.print_version().await,
|
||||
Commands::Conversations => client.print_conversations().await,
|
||||
Commands::Messages { conversation_id } => client.print_messages(conversation_id).await,
|
||||
Commands::RawUpdates => client.print_raw_updates().await,
|
||||
Commands::Events => client.print_events().await,
|
||||
Commands::SendMessage {
|
||||
conversation_id,
|
||||
message,
|
||||
} => client.send_message(conversation_id, message).await,
|
||||
Commands::Mark { conversation_id } => {
|
||||
client.mark_conversation_as_read(conversation_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientCli {
|
||||
api: HTTPAPIClient<InMemoryAuthenticationStore>,
|
||||
}
|
||||
|
||||
impl ClientCli {
|
||||
pub fn new() -> Self {
|
||||
let api = make_api_client_from_env();
|
||||
Self { api }
|
||||
}
|
||||
|
||||
pub async fn print_version(&mut self) -> Result<()> {
|
||||
let version = self.api.get_version().await?;
|
||||
println!("Version: {}", version);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn print_conversations(&mut self) -> Result<()> {
|
||||
let conversations = self.api.get_conversations().await?;
|
||||
for conversation in conversations {
|
||||
println!("{}", ConversationPrinter::new(&conversation.into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn print_messages(&mut self, conversation_id: String) -> Result<()> {
|
||||
let messages = self
|
||||
.api
|
||||
.get_messages(&conversation_id, None, None, None)
|
||||
.await?;
|
||||
for message in messages {
|
||||
println!("{}", MessagePrinter::new(&message.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn print_events(&mut self) -> Result<()> {
|
||||
let socket = self.api.open_event_socket(None).await?;
|
||||
|
||||
let (mut stream, _) = socket.events().await;
|
||||
while let Some(Ok(socket_event)) = stream.next().await {
|
||||
match socket_event {
|
||||
SocketEvent::Update(event) => match event.data {
|
||||
EventData::ConversationChanged(conversation) => {
|
||||
println!("Conversation changed: {}", conversation.guid);
|
||||
}
|
||||
EventData::MessageReceived(conversation, message) => {
|
||||
println!(
|
||||
"Message received: msg: {} conversation: {}",
|
||||
message.guid, conversation.guid
|
||||
);
|
||||
}
|
||||
},
|
||||
SocketEvent::Pong => {
|
||||
println!("Pong");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn print_raw_updates(&mut self) -> Result<()> {
|
||||
let socket = self.api.open_event_socket(None).await?;
|
||||
|
||||
println!("Listening for raw updates...");
|
||||
let mut stream = socket.raw_updates().await;
|
||||
while let Some(Ok(update)) = stream.next().await {
|
||||
match update {
|
||||
SocketUpdate::Update(updates) => {
|
||||
for update in updates {
|
||||
println!("Got update: {:?}", update);
|
||||
}
|
||||
}
|
||||
SocketUpdate::Pong => {
|
||||
println!("Pong");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(&mut self, conversation_id: String, message: String) -> Result<()> {
|
||||
let outgoing_message = OutgoingMessage::builder()
|
||||
.conversation_id(conversation_id)
|
||||
.text(message)
|
||||
.build();
|
||||
|
||||
let message = self.api.send_message(&outgoing_message).await?;
|
||||
println!("Message sent: {}", message.guid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
||||
self.api.mark_conversation_as_read(&conversation_id).await?;
|
||||
println!("Conversation marked as read: {}", conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
212
core/kpcli/src/daemon/dbus.rs
Normal file
212
core/kpcli/src/daemon/dbus.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Linux-only D-Bus implementation of the `DaemonInterface`.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use super::{ConfigCommands, DaemonInterface};
|
||||
use crate::printers::{ConversationPrinter, MessagePrinter};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use dbus::blocking::{Connection, Proxy};
|
||||
use prettytable::table;
|
||||
|
||||
const DBUS_NAME: &str = "net.buzzert.kordophonecd";
|
||||
const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon";
|
||||
|
||||
#[allow(unused)]
|
||||
mod dbus_interface {
|
||||
#![allow(unused)]
|
||||
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
||||
}
|
||||
|
||||
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
||||
use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings;
|
||||
|
||||
pub struct DBusDaemonInterface {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DBusDaemonInterface {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
conn: Connection::new_session()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn proxy(&self) -> Proxy<&Connection> {
|
||||
self.conn
|
||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||
}
|
||||
|
||||
async fn print_settings(&mut self) -> Result<()> {
|
||||
let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default();
|
||||
let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default();
|
||||
|
||||
let table = table!([
|
||||
b->"Server URL", &server_url
|
||||
], [
|
||||
b->"Username", &username
|
||||
]);
|
||||
table.printstd();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_server_url(&mut self, url: String) -> Result<()> {
|
||||
KordophoneSettings::set_server_url(&self.proxy(), url)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e))
|
||||
}
|
||||
|
||||
async fn set_username(&mut self, username: String) -> Result<()> {
|
||||
KordophoneSettings::set_username(&self.proxy(), username)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set username: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DaemonInterface for DBusDaemonInterface {
|
||||
async fn print_version(&mut self) -> Result<()> {
|
||||
let version = KordophoneRepository::get_version(&self.proxy())?;
|
||||
println!("Server version: {}", version);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn print_conversations(&mut self) -> Result<()> {
|
||||
let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?;
|
||||
println!("Number of conversations: {}", conversations.len());
|
||||
for conversation in conversations {
|
||||
println!("{}", ConversationPrinter::new(&conversation.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_conversations(&mut self, conversation_id: Option<String>) -> Result<()> {
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e))
|
||||
} else {
|
||||
KordophoneRepository::sync_all_conversations(&self.proxy())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_conversations_list(&mut self) -> Result<()> {
|
||||
KordophoneRepository::sync_conversation_list(&self.proxy())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e))
|
||||
}
|
||||
|
||||
async fn print_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
let messages = KordophoneRepository::get_messages(
|
||||
&self.proxy(),
|
||||
&conversation_id,
|
||||
&last_message_id.unwrap_or_default(),
|
||||
)?;
|
||||
println!("Number of messages: {}", messages.len());
|
||||
for message in messages {
|
||||
println!("{}", MessagePrinter::new(&message.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enqueue_outgoing_message(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let attachment_guids: Vec<&str> = vec![];
|
||||
let outgoing_message_id = KordophoneRepository::send_message(
|
||||
&self.proxy(),
|
||||
&conversation_id,
|
||||
&text,
|
||||
attachment_guids,
|
||||
)?;
|
||||
println!("Outgoing message ID: {}", outgoing_message_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_signals(&mut self) -> Result<()> {
|
||||
use dbus::Message;
|
||||
mod dbus_signals {
|
||||
pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated;
|
||||
}
|
||||
|
||||
let _id = self.proxy().match_signal(
|
||||
|_: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| {
|
||||
println!("Signal: Conversations updated");
|
||||
true
|
||||
},
|
||||
);
|
||||
|
||||
println!("Waiting for signals...");
|
||||
loop {
|
||||
self.conn.process(std::time::Duration::from_millis(1000))?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn config(&mut self, cmd: ConfigCommands) -> Result<()> {
|
||||
match cmd {
|
||||
ConfigCommands::Print => self.print_settings().await,
|
||||
ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await,
|
||||
ConfigCommands::SetUsername { username } => self.set_username(username).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_all_conversations(&mut self) -> Result<()> {
|
||||
KordophoneRepository::delete_all_conversations(&self.proxy())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e))
|
||||
}
|
||||
|
||||
async fn download_attachment(&mut self, attachment_id: String) -> Result<()> {
|
||||
// Trigger download.
|
||||
KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?;
|
||||
|
||||
// Get attachment info.
|
||||
let attachment_info =
|
||||
KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?;
|
||||
let (path, _preview_path, downloaded, _preview_downloaded) = attachment_info;
|
||||
|
||||
if downloaded {
|
||||
println!("Attachment already downloaded: {}", path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Downloading attachment: {}", attachment_id);
|
||||
|
||||
// Attach to the signal that the attachment has been downloaded.
|
||||
let download_path = path.clone();
|
||||
let _id = self.proxy().match_signal(
|
||||
move |_: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
println!("Signal: Attachment downloaded: {}", download_path);
|
||||
std::process::exit(0);
|
||||
},
|
||||
);
|
||||
|
||||
let _id = self.proxy().match_signal(
|
||||
|h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
println!("Signal: Attachment download failed: {}", h.attachment_id);
|
||||
std::process::exit(1);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the signal.
|
||||
loop {
|
||||
self.conn.process(std::time::Duration::from_millis(1000))?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_attachment(&mut self, path: String) -> Result<()> {
|
||||
let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?;
|
||||
println!("Upload GUID: {}", upload_guid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
||||
KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e))
|
||||
}
|
||||
}
|
||||
224
core/kpcli/src/daemon/mod.rs
Normal file
224
core/kpcli/src/daemon/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use clap::Subcommand;
|
||||
|
||||
// Platform-specific modules
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dbus;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod xpc;
|
||||
|
||||
#[cfg_attr(target_os = "macos", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_os = "macos"), async_trait)]
|
||||
pub trait DaemonInterface {
|
||||
async fn print_version(&mut self) -> Result<()>;
|
||||
async fn print_conversations(&mut self) -> Result<()>;
|
||||
async fn sync_conversations(&mut self, conversation_id: Option<String>) -> Result<()>;
|
||||
async fn sync_conversations_list(&mut self) -> Result<()>;
|
||||
async fn print_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<()>;
|
||||
async fn enqueue_outgoing_message(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
) -> Result<()>;
|
||||
async fn wait_for_signals(&mut self) -> Result<()>;
|
||||
async fn config(&mut self, cmd: ConfigCommands) -> Result<()>;
|
||||
async fn delete_all_conversations(&mut self) -> Result<()>;
|
||||
async fn download_attachment(&mut self, attachment_id: String) -> Result<()>;
|
||||
async fn upload_attachment(&mut self, path: String) -> Result<()>;
|
||||
async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||
}
|
||||
|
||||
struct StubDaemonInterface;
|
||||
impl StubDaemonInterface {
|
||||
fn new() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "macos", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_os = "macos"), async_trait)]
|
||||
impl DaemonInterface for StubDaemonInterface {
|
||||
async fn print_version(&mut self) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn print_conversations(&mut self) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn sync_conversations(&mut self, _conversation_id: Option<String>) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn sync_conversations_list(&mut self) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn print_messages(
|
||||
&mut self,
|
||||
_conversation_id: String,
|
||||
_last_message_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn enqueue_outgoing_message(
|
||||
&mut self,
|
||||
_conversation_id: String,
|
||||
_text: String,
|
||||
) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn wait_for_signals(&mut self) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn delete_all_conversations(&mut self) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn upload_attachment(&mut self, _path: String) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Daemon interface not implemented on this platform"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_daemon_interface() -> Result<Box<dyn DaemonInterface>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(Box::new(dbus::DBusDaemonInterface::new()?))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Box::new(xpc::XpcDaemonInterface::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
Ok(Box::new(StubDaemonInterface::new()?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Gets all known conversations.
|
||||
Conversations,
|
||||
|
||||
/// Runs a full sync operation for a conversation and its messages.
|
||||
Sync { conversation_id: Option<String> },
|
||||
|
||||
/// Runs a sync operation for the conversation list.
|
||||
SyncList,
|
||||
|
||||
/// Prints the server Kordophone version.
|
||||
Version,
|
||||
|
||||
/// Configuration options
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommands,
|
||||
},
|
||||
|
||||
/// Waits for signals from the daemon.
|
||||
Signals,
|
||||
|
||||
/// Prints the messages for a conversation.
|
||||
Messages {
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Deletes all conversations.
|
||||
DeleteAllConversations,
|
||||
|
||||
/// Enqueues an outgoing message to be sent to a conversation.
|
||||
SendMessage {
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Downloads an attachment from the server to the attachment store. Returns the path to the attachment.
|
||||
DownloadAttachment { attachment_id: String },
|
||||
|
||||
/// Uploads an attachment to the server, returns upload guid.
|
||||
UploadAttachment { path: String },
|
||||
|
||||
/// Marks a conversation as read.
|
||||
MarkConversationAsRead { conversation_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ConfigCommands {
|
||||
/// Prints the current settings.
|
||||
Print,
|
||||
|
||||
/// Sets the server URL.
|
||||
SetServerUrl { url: String },
|
||||
|
||||
/// Sets the username.
|
||||
SetUsername { username: String },
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub async fn run(cmd: Commands) -> Result<()> {
|
||||
let mut client = new_daemon_interface()?;
|
||||
match cmd {
|
||||
Commands::Version => client.print_version().await,
|
||||
Commands::Conversations => client.print_conversations().await,
|
||||
Commands::Sync { conversation_id } => client.sync_conversations(conversation_id).await,
|
||||
Commands::SyncList => client.sync_conversations_list().await,
|
||||
Commands::Config { command } => client.config(command).await,
|
||||
Commands::Signals => client.wait_for_signals().await,
|
||||
Commands::Messages {
|
||||
conversation_id,
|
||||
last_message_id,
|
||||
} => {
|
||||
client
|
||||
.print_messages(conversation_id, last_message_id)
|
||||
.await
|
||||
}
|
||||
Commands::DeleteAllConversations => client.delete_all_conversations().await,
|
||||
Commands::SendMessage {
|
||||
conversation_id,
|
||||
text,
|
||||
} => client.enqueue_outgoing_message(conversation_id, text).await,
|
||||
Commands::UploadAttachment { path } => client.upload_attachment(path).await,
|
||||
Commands::DownloadAttachment { attachment_id } => {
|
||||
client.download_attachment(attachment_id).await
|
||||
}
|
||||
Commands::MarkConversationAsRead { conversation_id } => {
|
||||
client.mark_conversation_as_read(conversation_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
604
core/kpcli/src/daemon/xpc.rs
Normal file
604
core/kpcli/src/daemon/xpc.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
use super::{ConfigCommands, DaemonInterface};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::ops::Deref;
|
||||
use std::{pin::Pin, task::Poll};
|
||||
|
||||
use xpc_connection::Message;
|
||||
|
||||
use futures::{
|
||||
channel::mpsc::{unbounded as unbounded_channel, UnboundedReceiver, UnboundedSender},
|
||||
Stream,
|
||||
};
|
||||
|
||||
const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0";
|
||||
|
||||
const GET_VERSION_METHOD: &str = "GetVersion";
|
||||
const GET_CONVERSATIONS_METHOD: &str = "GetConversations";
|
||||
|
||||
// We can't use XPCClient from xpc-connection because of some strange decisions with which flags
|
||||
// are passed to xpc_connection_create_mach_service.
|
||||
struct XPCClient {
|
||||
connection: xpc_connection_sys::xpc_connection_t,
|
||||
receiver: UnboundedReceiver<Message>,
|
||||
sender: UnboundedSender<Message>,
|
||||
event_handler_is_running: bool,
|
||||
}
|
||||
|
||||
impl XPCClient {
|
||||
pub fn connect(name: impl AsRef<CStr>) -> Self {
|
||||
use block::ConcreteBlock;
|
||||
use xpc_connection::xpc_object_to_message;
|
||||
use xpc_connection_sys::xpc_connection_resume;
|
||||
use xpc_connection_sys::xpc_connection_set_event_handler;
|
||||
|
||||
let name = name.as_ref();
|
||||
let connection = unsafe {
|
||||
xpc_connection_sys::xpc_connection_create_mach_service(
|
||||
name.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
let (sender, receiver) = unbounded_channel();
|
||||
let sender_clone = sender.clone();
|
||||
|
||||
let block = ConcreteBlock::new(move |event| {
|
||||
let message = xpc_object_to_message(event);
|
||||
sender_clone.unbounded_send(message).ok()
|
||||
});
|
||||
|
||||
let block = block.copy();
|
||||
|
||||
unsafe {
|
||||
xpc_connection_set_event_handler(connection, block.deref() as *const _ as *mut _);
|
||||
xpc_connection_resume(connection);
|
||||
}
|
||||
|
||||
Self {
|
||||
connection,
|
||||
receiver,
|
||||
sender,
|
||||
event_handler_is_running: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message(&self, message: Message) {
|
||||
use xpc_connection::message_to_xpc_object;
|
||||
use xpc_connection_sys::xpc_connection_send_message;
|
||||
use xpc_connection_sys::xpc_release;
|
||||
|
||||
let xpc_object = message_to_xpc_object(message);
|
||||
unsafe {
|
||||
xpc_connection_send_message(self.connection, xpc_object);
|
||||
xpc_release(xpc_object);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message_with_reply(&self, message: Message) -> Message {
|
||||
use xpc_connection::message_to_xpc_object;
|
||||
use xpc_connection::xpc_object_to_message;
|
||||
use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release};
|
||||
|
||||
unsafe {
|
||||
let xobj = message_to_xpc_object(message);
|
||||
let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj);
|
||||
xpc_release(xobj);
|
||||
let msg = xpc_object_to_message(reply);
|
||||
if !reply.is_null() {
|
||||
xpc_release(reply);
|
||||
}
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XPCClient {
|
||||
fn drop(&mut self) {
|
||||
use xpc_connection_sys::xpc_object_t;
|
||||
use xpc_connection_sys::xpc_release;
|
||||
|
||||
unsafe { xpc_release(self.connection as xpc_object_t) };
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for XPCClient {
|
||||
type Item = Message;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> Poll<Option<Self::Item>> {
|
||||
match Stream::poll_next(Pin::new(&mut self.receiver), cx) {
|
||||
Poll::Ready(Some(Message::Error(xpc_connection::MessageError::ConnectionInvalid))) => {
|
||||
self.event_handler_is_running = false;
|
||||
Poll::Ready(None)
|
||||
}
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for XPCClient {}
|
||||
|
||||
/// XPC-based implementation of DaemonInterface that sends method calls to the daemon over libxpc.
|
||||
pub struct XpcDaemonInterface;
|
||||
|
||||
impl XpcDaemonInterface {
|
||||
/// Create a new XpcDaemonInterface. No state is held.
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn build_service_name() -> Result<CString> {
|
||||
let service_name = SERVICE_NAME.trim_end_matches('\0');
|
||||
Ok(CString::new(service_name)?)
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
method: &str,
|
||||
args: Option<HashMap<CString, Message>>,
|
||||
) -> HashMap<CString, Message> {
|
||||
let mut request = HashMap::new();
|
||||
request.insert(
|
||||
CString::new("method").unwrap(),
|
||||
Message::String(CString::new(method).unwrap()),
|
||||
);
|
||||
if let Some(arguments) = args {
|
||||
request.insert(
|
||||
CString::new("arguments").unwrap(),
|
||||
Message::Dictionary(arguments),
|
||||
);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
async fn call_method(
|
||||
&self,
|
||||
client: &mut XPCClient,
|
||||
method: &str,
|
||||
args: Option<HashMap<CString, Message>>,
|
||||
) -> anyhow::Result<HashMap<CString, Message>> {
|
||||
let request = Self::build_request(method, args);
|
||||
let reply = client.send_message_with_reply(Message::Dictionary(request));
|
||||
match reply {
|
||||
Message::Dictionary(map) => Ok(map),
|
||||
other => Err(anyhow::anyhow!("Unexpected XPC reply: {:?}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn key(k: &str) -> CString {
|
||||
CString::new(k).unwrap()
|
||||
}
|
||||
|
||||
fn get_string<'a>(map: &'a HashMap<CString, Message>, key: &str) -> Option<&'a CStr> {
|
||||
map.get(&Self::key(key)).and_then(|v| match v {
|
||||
Message::String(s) => Some(s.as_c_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_i64_from_str(map: &HashMap<CString, Message>, key: &str) -> Option<i64> {
|
||||
Self::get_string(map, key).and_then(|s| s.to_string_lossy().parse().ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl DaemonInterface for XpcDaemonInterface {
|
||||
async fn print_version(&mut self) -> Result<()> {
|
||||
// Build service name and connect
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
|
||||
// Call generic method and parse reply
|
||||
let map = self
|
||||
.call_method(&mut client, GET_VERSION_METHOD, None)
|
||||
.await?;
|
||||
if let Some(ver) = Self::get_string(&map, "version") {
|
||||
println!("Server version: {}", ver.to_string_lossy());
|
||||
Ok(())
|
||||
} else if let Some(ty) = Self::get_string(&map, "type") {
|
||||
println!("XPC replied with type: {}", ty.to_string_lossy());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"Unexpected XPC reply payload for GetVersion"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining methods unimplemented on macOS
|
||||
async fn print_conversations(&mut self) -> Result<()> {
|
||||
// Connect
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
|
||||
// Build arguments: limit=100, offset=0 (string-encoded for portability)
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
CString::new("limit").unwrap(),
|
||||
Message::String(CString::new("100").unwrap()),
|
||||
);
|
||||
args.insert(
|
||||
CString::new("offset").unwrap(),
|
||||
Message::String(CString::new("0").unwrap()),
|
||||
);
|
||||
|
||||
// Call
|
||||
let reply = self
|
||||
.call_method(&mut client, GET_CONVERSATIONS_METHOD, Some(args))
|
||||
.await?;
|
||||
|
||||
// Expect an array under "conversations"
|
||||
match reply.get(&Self::key("conversations")) {
|
||||
Some(Message::Array(items)) => {
|
||||
println!("Number of conversations: {}", items.len());
|
||||
|
||||
for item in items {
|
||||
if let Message::Dictionary(map) = item {
|
||||
// Convert to PrintableConversation
|
||||
let guid = Self::get_string(map, "guid")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let display_name = Self::get_string(map, "display_name")
|
||||
.map(|s| s.to_string_lossy().into_owned());
|
||||
let last_preview = Self::get_string(map, "last_message_preview")
|
||||
.map(|s| s.to_string_lossy().into_owned());
|
||||
|
||||
let unread_count =
|
||||
Self::get_i64_from_str(map, "unread_count").unwrap_or(0) as i32;
|
||||
let date_ts: i64 = Self::get_i64_from_str(map, "date").unwrap_or(0);
|
||||
|
||||
let participants: Vec<String> = match map.get(&Self::key("participants")) {
|
||||
Some(Message::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
Message::String(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Build PrintableConversation directly
|
||||
let conv = crate::printers::PrintableConversation {
|
||||
guid,
|
||||
display_name,
|
||||
last_message_preview: last_preview,
|
||||
unread_count,
|
||||
date: time::OffsetDateTime::from_unix_timestamp(date_ts)
|
||||
.unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH),
|
||||
participants,
|
||||
};
|
||||
|
||||
println!("{}", crate::printers::ConversationPrinter::new(&conv));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(anyhow::anyhow!(
|
||||
"Unexpected conversations payload: {:?}",
|
||||
other
|
||||
)),
|
||||
None => Err(anyhow::anyhow!("Missing conversations in reply")),
|
||||
}
|
||||
}
|
||||
async fn sync_conversations(&mut self, _conversation_id: Option<String>) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
|
||||
if let Some(id) = _conversation_id {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(CString::new(id).unwrap()),
|
||||
);
|
||||
let _ = self
|
||||
.call_method(&mut client, "SyncConversation", Some(args))
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.call_method(&mut client, "SyncAllConversations", None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn sync_conversations_list(&mut self) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let _ = self
|
||||
.call_method(&mut client, "SyncConversationList", None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn print_messages(
|
||||
&mut self,
|
||||
_conversation_id: String,
|
||||
_last_message_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(CString::new(_conversation_id).unwrap()),
|
||||
);
|
||||
if let Some(last) = _last_message_id {
|
||||
args.insert(
|
||||
Self::key("last_message_id"),
|
||||
Message::String(CString::new(last).unwrap()),
|
||||
);
|
||||
}
|
||||
|
||||
let reply = self
|
||||
.call_method(&mut client, "GetMessages", Some(args))
|
||||
.await?;
|
||||
match reply.get(&Self::key("messages")) {
|
||||
Some(Message::Array(items)) => {
|
||||
println!("Number of messages: {}", items.len());
|
||||
for item in items {
|
||||
if let Message::Dictionary(map) = item {
|
||||
let guid = Self::get_string(map, "id")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let sender = Self::get_string(map, "sender")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let text = Self::get_string(map, "text")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let date_ts = Self::get_i64_from_str(map, "date").unwrap_or(0);
|
||||
let msg = crate::printers::PrintableMessage {
|
||||
guid,
|
||||
date: time::OffsetDateTime::from_unix_timestamp(date_ts)
|
||||
.unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH),
|
||||
sender,
|
||||
text,
|
||||
file_transfer_guids: vec![],
|
||||
attachment_metadata: None,
|
||||
};
|
||||
println!("{}", crate::printers::MessagePrinter::new(&msg));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Unexpected messages payload")),
|
||||
}
|
||||
}
|
||||
async fn enqueue_outgoing_message(
|
||||
&mut self,
|
||||
_conversation_id: String,
|
||||
_text: String,
|
||||
) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(CString::new(_conversation_id).unwrap()),
|
||||
);
|
||||
args.insert(
|
||||
Self::key("text"),
|
||||
Message::String(CString::new(_text).unwrap()),
|
||||
);
|
||||
let reply = self
|
||||
.call_method(&mut client, "SendMessage", Some(args))
|
||||
.await?;
|
||||
if let Some(uuid) = Self::get_string(&reply, "uuid") {
|
||||
println!("Outgoing message ID: {}", uuid.to_string_lossy());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn wait_for_signals(&mut self) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
|
||||
// Subscribe to begin receiving signals on this connection
|
||||
eprintln!("[kpcli] Sending SubscribeSignals");
|
||||
client.send_message(Message::Dictionary(Self::build_request(
|
||||
"SubscribeSignals",
|
||||
None,
|
||||
)));
|
||||
|
||||
println!("Waiting for XPC signals...");
|
||||
while let Some(msg) = client.next().await {
|
||||
match msg {
|
||||
Message::Dictionary(map) => {
|
||||
eprintln!("[kpcli] Received signal dictionary");
|
||||
let name_key = Self::key("name");
|
||||
let args_key = Self::key("arguments");
|
||||
let name = match map.get(&name_key) {
|
||||
Some(Message::String(s)) => s.to_string_lossy().into_owned(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
match name.as_str() {
|
||||
"ConversationsUpdated" => {
|
||||
println!("Signal: Conversations updated");
|
||||
}
|
||||
"MessagesUpdated" => {
|
||||
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
|
||||
if let Some(Message::String(cid)) =
|
||||
args.get(&Self::key("conversation_id"))
|
||||
{
|
||||
println!(
|
||||
"Signal: Messages updated for conversation {}",
|
||||
cid.to_string_lossy()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"UpdateStreamReconnected" => {
|
||||
println!("Signal: Update stream reconnected");
|
||||
}
|
||||
"AttachmentDownloadCompleted" => {
|
||||
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
|
||||
if let Some(Message::String(aid)) =
|
||||
args.get(&Self::key("attachment_id"))
|
||||
{
|
||||
println!(
|
||||
"Signal: Attachment downloaded: {}",
|
||||
aid.to_string_lossy()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"AttachmentDownloadFailed" => {
|
||||
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
|
||||
if let Some(Message::String(aid)) =
|
||||
args.get(&Self::key("attachment_id"))
|
||||
{
|
||||
eprintln!(
|
||||
"Signal: Attachment download failed: {}",
|
||||
aid.to_string_lossy()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"AttachmentUploadCompleted" => {
|
||||
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
|
||||
let upload = args
|
||||
.get(&Self::key("upload_guid"))
|
||||
.and_then(|v| match v {
|
||||
Message::String(s) => {
|
||||
Some(s.to_string_lossy().into_owned())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let attachment = args
|
||||
.get(&Self::key("attachment_guid"))
|
||||
.and_then(|v| match v {
|
||||
Message::String(s) => {
|
||||
Some(s.to_string_lossy().into_owned())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"Signal: Attachment uploaded: upload={}, attachment={}",
|
||||
upload, attachment
|
||||
);
|
||||
}
|
||||
}
|
||||
"ConfigChanged" => {
|
||||
println!("Signal: Config changed");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Message::Error(xpc_connection::MessageError::ConnectionInvalid) => {
|
||||
eprintln!("[kpcli] XPC connection invalid");
|
||||
break;
|
||||
}
|
||||
other => {
|
||||
eprintln!("[kpcli] Unexpected XPC message: {:?}", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
match _cmd {
|
||||
ConfigCommands::Print => {
|
||||
let reply = self
|
||||
.call_method(&mut client, "GetAllSettings", None)
|
||||
.await?;
|
||||
let server_url = Self::get_string(&reply, "server_url")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let username = Self::get_string(&reply, "username")
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let table =
|
||||
prettytable::table!([b->"Server URL", &server_url], [b->"Username", &username]);
|
||||
table.printstd();
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommands::SetServerUrl { url } => {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("server_url"),
|
||||
Message::String(CString::new(url).unwrap()),
|
||||
);
|
||||
let _ = self
|
||||
.call_method(&mut client, "UpdateSettings", Some(args))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommands::SetUsername { username } => {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("username"),
|
||||
Message::String(CString::new(username).unwrap()),
|
||||
);
|
||||
let _ = self
|
||||
.call_method(&mut client, "UpdateSettings", Some(args))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn delete_all_conversations(&mut self) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let _ = self
|
||||
.call_method(&mut client, "DeleteAllConversations", None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("attachment_id"),
|
||||
Message::String(CString::new(_attachment_id).unwrap()),
|
||||
);
|
||||
args.insert(
|
||||
Self::key("preview"),
|
||||
Message::String(CString::new("false").unwrap()),
|
||||
);
|
||||
let _ = self
|
||||
.call_method(&mut client, "DownloadAttachment", Some(args))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn upload_attachment(&mut self, _path: String) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("path"),
|
||||
Message::String(CString::new(_path).unwrap()),
|
||||
);
|
||||
let reply = self
|
||||
.call_method(&mut client, "UploadAttachment", Some(args))
|
||||
.await?;
|
||||
if let Some(guid) = Self::get_string(&reply, "upload_guid") {
|
||||
println!("Upload GUID: {}", guid.to_string_lossy());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> {
|
||||
let mach_port_name = Self::build_service_name()?;
|
||||
let mut client = XPCClient::connect(&mach_port_name);
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(CString::new(_conversation_id).unwrap()),
|
||||
);
|
||||
let _ = self
|
||||
.call_method(&mut client, "MarkConversationAsRead", Some(args))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
239
core/kpcli/src/db/mod.rs
Normal file
239
core/kpcli/src/db/mod.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use kordophone::APIInterface;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
client,
|
||||
printers::{ConversationPrinter, MessagePrinter},
|
||||
};
|
||||
use kordophone_db::database::{Database, DatabaseAccess};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// For dealing with the table of cached conversations.
|
||||
Conversations {
|
||||
#[clap(subcommand)]
|
||||
command: ConversationCommands,
|
||||
},
|
||||
|
||||
/// For dealing with the table of cached messages.
|
||||
Messages {
|
||||
#[clap(subcommand)]
|
||||
command: MessageCommands,
|
||||
},
|
||||
|
||||
/// For managing settings in the database.
|
||||
Settings {
|
||||
#[clap(subcommand)]
|
||||
command: SettingsCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ConversationCommands {
|
||||
/// Lists all conversations currently in the database.
|
||||
List,
|
||||
|
||||
/// Syncs with an API client.
|
||||
Sync,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MessageCommands {
|
||||
/// Prints all messages in a conversation.
|
||||
List { conversation_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SettingsCommands {
|
||||
/// Lists all settings or gets a specific setting.
|
||||
Get {
|
||||
/// The key to get. If not provided, all settings will be listed.
|
||||
key: Option<String>,
|
||||
},
|
||||
|
||||
/// Sets a setting value.
|
||||
Put {
|
||||
/// The key to set.
|
||||
key: String,
|
||||
/// The value to set.
|
||||
value: String,
|
||||
},
|
||||
|
||||
/// Deletes a setting.
|
||||
Delete {
|
||||
/// The key to delete.
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub async fn run(cmd: Commands) -> Result<()> {
|
||||
let mut db = DbClient::new()?;
|
||||
match cmd {
|
||||
Commands::Conversations { command: cmd } => match cmd {
|
||||
ConversationCommands::List => db.print_conversations().await,
|
||||
ConversationCommands::Sync => db.sync_with_client().await,
|
||||
},
|
||||
Commands::Messages { command: cmd } => match cmd {
|
||||
MessageCommands::List { conversation_id } => {
|
||||
db.print_messages(&conversation_id).await
|
||||
}
|
||||
},
|
||||
Commands::Settings { command: cmd } => match cmd {
|
||||
SettingsCommands::Get { key } => db.get_setting(key).await,
|
||||
SettingsCommands::Put { key, value } => db.put_setting(key, value).await,
|
||||
SettingsCommands::Delete { key } => db.delete_setting(key).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DbClient {
|
||||
database: Database,
|
||||
}
|
||||
|
||||
impl DbClient {
|
||||
fn database_path() -> PathBuf {
|
||||
env::var("KORDOPHONE_DB_PATH")
|
||||
.unwrap_or_else(|_| {
|
||||
let temp_dir = env::temp_dir();
|
||||
temp_dir.join("kpcli_chat.db").to_str().unwrap().to_string()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self> {
|
||||
let path = Self::database_path();
|
||||
let path_str: &str = path.as_path().to_str().unwrap();
|
||||
|
||||
println!("kpcli: Using db at {}", path_str);
|
||||
|
||||
let db = Database::new(path_str)?;
|
||||
Ok(Self { database: db })
|
||||
}
|
||||
|
||||
pub async fn print_conversations(&mut self) -> Result<()> {
|
||||
let all_conversations = self
|
||||
.database
|
||||
.with_repository(|repository| repository.all_conversations(i32::MAX, 0))
|
||||
.await?;
|
||||
|
||||
println!("{} Conversations: ", all_conversations.len());
|
||||
for conversation in all_conversations {
|
||||
println!("{}", ConversationPrinter::new(&conversation.into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> {
|
||||
let messages = self
|
||||
.database
|
||||
.with_repository(|repository| repository.get_messages_for_conversation(conversation_id))
|
||||
.await?;
|
||||
|
||||
for message in messages {
|
||||
println!("{}", MessagePrinter::new(&message.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_with_client(&mut self) -> Result<()> {
|
||||
let mut client = client::make_api_client_from_env();
|
||||
let fetched_conversations = client.get_conversations().await?;
|
||||
let db_conversations: Vec<kordophone_db::models::Conversation> = fetched_conversations
|
||||
.into_iter()
|
||||
.map(kordophone_db::models::Conversation::from)
|
||||
.collect();
|
||||
|
||||
// Process each conversation
|
||||
for conversation in db_conversations {
|
||||
let conversation_id = conversation.guid.clone();
|
||||
|
||||
// Insert the conversation
|
||||
self.database
|
||||
.with_repository(|repository| repository.insert_conversation(conversation))
|
||||
.await?;
|
||||
|
||||
// Fetch and sync messages for this conversation
|
||||
let messages = client
|
||||
.get_messages(&conversation_id, None, None, None)
|
||||
.await?;
|
||||
let db_messages: Vec<kordophone_db::models::Message> = messages
|
||||
.into_iter()
|
||||
.map(kordophone_db::models::Message::from)
|
||||
.collect();
|
||||
|
||||
// Insert each message
|
||||
self.database
|
||||
.with_repository(|repository| -> Result<()> {
|
||||
for message in db_messages {
|
||||
repository.insert_message(&conversation_id, message)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_setting(&mut self, key: Option<String>) -> Result<()> {
|
||||
self.database
|
||||
.with_settings(|settings| {
|
||||
match key {
|
||||
Some(key) => {
|
||||
// Get a specific setting
|
||||
let value: Option<String> = settings.get(&key)?;
|
||||
match value {
|
||||
Some(v) => println!("{} = {}", key, v),
|
||||
None => println!("Setting '{}' not found", key),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// List all settings
|
||||
let keys = settings.list_keys()?;
|
||||
if keys.is_empty() {
|
||||
println!("No settings found");
|
||||
} else {
|
||||
println!("Settings:");
|
||||
for key in keys {
|
||||
let value: Option<String> = settings.get(&key)?;
|
||||
match value {
|
||||
Some(v) => println!(" {} = {}", key, v),
|
||||
None => println!(" {} = <error reading value>", key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn put_setting(&mut self, key: String, value: String) -> Result<()> {
|
||||
self.database
|
||||
.with_settings(|settings| {
|
||||
settings.put(&key, &value)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_setting(&mut self, key: String) -> Result<()> {
|
||||
self.database
|
||||
.with_settings(|settings| {
|
||||
let count = settings.del(&key)?;
|
||||
if count == 0 {
|
||||
println!("Setting '{}' not found", key);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
69
core/kpcli/src/main.rs
Normal file
69
core/kpcli/src/main.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
mod client;
|
||||
mod daemon;
|
||||
mod db;
|
||||
mod printers;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use log::LevelFilter;
|
||||
|
||||
/// A command line interface for the Kordophone library and daemon
|
||||
#[derive(Parser)]
|
||||
#[command(name = "kpcli")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Commands for api client operations
|
||||
Client {
|
||||
#[command(subcommand)]
|
||||
command: client::Commands,
|
||||
},
|
||||
|
||||
/// Commands for the cache database
|
||||
Db {
|
||||
#[command(subcommand)]
|
||||
command: db::Commands,
|
||||
},
|
||||
|
||||
/// Commands for interacting with the daemon
|
||||
Daemon {
|
||||
#[command(subcommand)]
|
||||
command: daemon::Commands,
|
||||
},
|
||||
}
|
||||
|
||||
async fn run_command(command: Commands) -> Result<()> {
|
||||
match command {
|
||||
Commands::Client { command } => client::Commands::run(command).await,
|
||||
Commands::Db { command } => db::Commands::run(command).await,
|
||||
Commands::Daemon { command } => daemon::Commands::run(command).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_logging() {
|
||||
// Weird: is this the best way to do this?
|
||||
let log_level = std::env::var("RUST_LOG")
|
||||
.map(|s| s.parse::<LevelFilter>().unwrap_or(LevelFilter::Info))
|
||||
.unwrap_or(LevelFilter::Info);
|
||||
|
||||
env_logger::Builder::from_default_env()
|
||||
.format_timestamp_secs()
|
||||
.filter_level(log_level)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
initialize_logging();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
run_command(cli.command)
|
||||
.await
|
||||
.map_err(|e| println!("Error: {}", e))
|
||||
.err();
|
||||
}
|
||||
273
core/kpcli/src/printers.rs
Normal file
273
core/kpcli/src/printers.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use kordophone::model::message::AttachmentMetadata;
|
||||
use pretty::RcDoc;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use dbus::arg::{self, RefArg};
|
||||
|
||||
pub struct PrintableConversation {
|
||||
pub guid: String,
|
||||
pub date: OffsetDateTime,
|
||||
pub unread_count: i32,
|
||||
pub last_message_preview: Option<String>,
|
||||
pub participants: Vec<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<kordophone::model::Conversation> for PrintableConversation {
|
||||
fn from(value: kordophone::model::Conversation) -> Self {
|
||||
Self {
|
||||
guid: value.guid,
|
||||
date: value.date,
|
||||
unread_count: value.unread_count,
|
||||
last_message_preview: value.last_message_preview,
|
||||
participants: value.participant_display_names,
|
||||
display_name: value.display_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kordophone_db::models::Conversation> for PrintableConversation {
|
||||
fn from(value: kordophone_db::models::Conversation) -> Self {
|
||||
Self {
|
||||
guid: value.guid,
|
||||
date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(),
|
||||
unread_count: value.unread_count.into(),
|
||||
last_message_preview: value.last_message_preview,
|
||||
participants: value
|
||||
.participants
|
||||
.into_iter()
|
||||
.map(|p| p.display_name())
|
||||
.collect(),
|
||||
display_name: value.display_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl From<dbus::arg::PropMap> for PrintableConversation {
|
||||
fn from(value: dbus::arg::PropMap) -> Self {
|
||||
Self {
|
||||
guid: value.get("guid").unwrap().as_str().unwrap().to_string(),
|
||||
date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap())
|
||||
.unwrap(),
|
||||
unread_count: value
|
||||
.get("unread_count")
|
||||
.unwrap()
|
||||
.as_i64()
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
last_message_preview: value
|
||||
.get("last_message_preview")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
participants: value
|
||||
.get("participants")
|
||||
.unwrap()
|
||||
.0
|
||||
.as_iter()
|
||||
.unwrap()
|
||||
.map(|s| s.as_str().unwrap().to_string())
|
||||
.collect(),
|
||||
display_name: value
|
||||
.get("display_name")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrintableMessage {
|
||||
pub guid: String,
|
||||
pub date: OffsetDateTime,
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub file_transfer_guids: Vec<String>,
|
||||
pub attachment_metadata: Option<HashMap<String, AttachmentMetadata>>,
|
||||
}
|
||||
|
||||
impl From<kordophone::model::Message> for PrintableMessage {
|
||||
fn from(value: kordophone::model::Message) -> Self {
|
||||
Self {
|
||||
guid: value.guid,
|
||||
date: value.date,
|
||||
sender: value.sender.unwrap_or("<me>".to_string()),
|
||||
text: value.text,
|
||||
file_transfer_guids: value.file_transfer_guids,
|
||||
attachment_metadata: value.attachment_metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kordophone_db::models::Message> for PrintableMessage {
|
||||
fn from(value: kordophone_db::models::Message) -> Self {
|
||||
Self {
|
||||
guid: value.id,
|
||||
date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(),
|
||||
sender: value.sender.display_name(),
|
||||
text: value.text,
|
||||
file_transfer_guids: value.file_transfer_guids,
|
||||
attachment_metadata: value.attachment_metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl From<dbus::arg::PropMap> for PrintableMessage {
|
||||
fn from(value: dbus::arg::PropMap) -> Self {
|
||||
// Parse file transfer GUIDs from JSON if present
|
||||
let file_transfer_guids = value
|
||||
.get("file_transfer_guids")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|json_str| serde_json::from_str(json_str).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse attachment metadata from JSON if present
|
||||
let attachment_metadata = value
|
||||
.get("attachment_metadata")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|json_str| serde_json::from_str(json_str).ok());
|
||||
|
||||
Self {
|
||||
guid: value.get("id").unwrap().as_str().unwrap().to_string(),
|
||||
date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap())
|
||||
.unwrap(),
|
||||
sender: value.get("sender").unwrap().as_str().unwrap().to_string(),
|
||||
text: value.get("text").unwrap().as_str().unwrap().to_string(),
|
||||
file_transfer_guids,
|
||||
attachment_metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConversationPrinter<'a> {
|
||||
doc: RcDoc<'a, PrintableConversation>,
|
||||
}
|
||||
|
||||
impl<'a> ConversationPrinter<'a> {
|
||||
pub fn new(conversation: &'a PrintableConversation) -> Self {
|
||||
let preview = conversation
|
||||
.last_message_preview
|
||||
.as_deref()
|
||||
.unwrap_or("<null>")
|
||||
.replace('\n', " ");
|
||||
|
||||
let doc = RcDoc::text(format!("<Conversation: \"{}\"", &conversation.guid))
|
||||
.append(
|
||||
RcDoc::line()
|
||||
.append("Display Name: ")
|
||||
.append(conversation.display_name.as_deref().unwrap_or("<null>"))
|
||||
.append(RcDoc::line())
|
||||
.append("Date: ")
|
||||
.append(conversation.date.to_string())
|
||||
.append(RcDoc::line())
|
||||
.append("Unread Count: ")
|
||||
.append(conversation.unread_count.to_string())
|
||||
.append(RcDoc::line())
|
||||
.append("Participants: ")
|
||||
.append("[")
|
||||
.append(
|
||||
RcDoc::line()
|
||||
.append(
|
||||
conversation
|
||||
.participants
|
||||
.iter()
|
||||
.map(|name| RcDoc::text(name).append(",").append(RcDoc::line()))
|
||||
.fold(RcDoc::nil(), |acc, x| acc.append(x)),
|
||||
)
|
||||
.nest(4),
|
||||
)
|
||||
.append("]")
|
||||
.append(RcDoc::line())
|
||||
.append("Last Message Preview: ")
|
||||
.append(preview)
|
||||
.nest(4),
|
||||
)
|
||||
.append(RcDoc::line())
|
||||
.append(">");
|
||||
|
||||
ConversationPrinter { doc }
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ConversationPrinter<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.doc.render_fmt(180, f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MessagePrinter<'a> {
|
||||
doc: RcDoc<'a, PrintableMessage>,
|
||||
}
|
||||
|
||||
impl Display for MessagePrinter<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.doc.render_fmt(180, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MessagePrinter<'a> {
|
||||
pub fn new(message: &'a PrintableMessage) -> Self {
|
||||
let mut doc = RcDoc::text(format!("<Message: \"{}\"", &message.guid)).append(
|
||||
RcDoc::line()
|
||||
.append("Date: ")
|
||||
.append(message.date.to_string())
|
||||
.append(RcDoc::line())
|
||||
.append("Sender: ")
|
||||
.append(&message.sender)
|
||||
.append(RcDoc::line())
|
||||
.append("Body: ")
|
||||
.append(&message.text)
|
||||
.nest(4),
|
||||
);
|
||||
|
||||
// Add file transfer GUIDs and attachment metadata if present
|
||||
if !message.file_transfer_guids.is_empty() {
|
||||
doc = doc.append(RcDoc::line()).append(
|
||||
RcDoc::line()
|
||||
.append("Attachments:")
|
||||
.append(
|
||||
message
|
||||
.file_transfer_guids
|
||||
.iter()
|
||||
.map(|guid| {
|
||||
let mut attachment_doc = RcDoc::line().append("- ").append(guid);
|
||||
|
||||
// Add metadata if available for this GUID
|
||||
if let Some(ref metadata) = message.attachment_metadata {
|
||||
if let Some(attachment_meta) = metadata.get(guid) {
|
||||
if let Some(ref attribution) =
|
||||
attachment_meta.attribution_info
|
||||
{
|
||||
if let (Some(width), Some(height)) =
|
||||
(attribution.width, attribution.height)
|
||||
{
|
||||
attachment_doc = attachment_doc
|
||||
.append(RcDoc::line())
|
||||
.append(" Dimensions: ")
|
||||
.append(width.to_string())
|
||||
.append(" × ")
|
||||
.append(height.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attachment_doc
|
||||
})
|
||||
.fold(RcDoc::nil(), |acc, x| acc.append(x)),
|
||||
)
|
||||
.nest(4),
|
||||
);
|
||||
}
|
||||
|
||||
doc = doc.append(RcDoc::line()).append(">");
|
||||
|
||||
MessagePrinter { doc }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user