Private
Public Access
1
0

Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'

git-subtree-dir: core
git-subtree-mainline: a07f3dcd23
git-subtree-split: b0dfc4146c
This commit is contained in:
2025-09-06 19:33:33 -07:00
83 changed files with 12352 additions and 0 deletions

View 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(())
}
}

View 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))
}
}

View 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
}
}
}
}

View 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
View 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
View 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
View 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 }
}
}