Compare commits
7 Commits
wip/new_me
...
release/an
| Author | SHA1 | Date | |
|---|---|---|---|
| d946e1256e | |||
| 7264cce5b8 | |||
| 6d098c9f76 | |||
| 2101aa7b14 | |||
| 65b3b9013a | |||
| 7056a7f836 | |||
| fd3660858e |
114
.gitea/workflows/android-release.yaml
Normal file
114
.gitea/workflows/android-release.yaml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'release/android/*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
ANDROID_SDK_ROOT: ${{ gitea.workspace }}/android-sdk
|
||||||
|
ANDROID_HOME: ${{ gitea.workspace }}/android-sdk
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Gitea's default act_runner labels map ubuntu-latest to node:16-bullseye,
|
||||||
|
# so keep the GitHub-hosted actions on their Node 16-compatible v3 line.
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ca-certificates git openjdk-17-jdk unzip wget
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Android SDK components
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/android-commandlinetools.zip
|
||||||
|
|
||||||
|
rm -rf "$ANDROID_SDK_ROOT"
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
|
||||||
|
unzip -q /tmp/android-commandlinetools.zip -d /tmp/android-commandlinetools
|
||||||
|
mv /tmp/android-commandlinetools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
|
||||||
|
# sdkmanager exits successfully once it has consumed all input, which
|
||||||
|
# causes `yes` to receive SIGPIPE and return 141 under `pipefail`.
|
||||||
|
set +o pipefail
|
||||||
|
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" --licenses
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" \
|
||||||
|
"platform-tools" \
|
||||||
|
"build-tools;33.0.1" \
|
||||||
|
"platforms;android-33"
|
||||||
|
|
||||||
|
- name: Build Android release APKs
|
||||||
|
working-directory: android
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
|
- name: Prepare release assets
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version="${RELEASE_TAG#release/android/}"
|
||||||
|
if [ -z "$version" ] || [ "$version" = "$RELEASE_TAG" ]; then
|
||||||
|
echo "Expected tag in the form release/android/{version}, got: $RELEASE_TAG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
assets_dir="${{ gitea.workspace }}/release-assets/android"
|
||||||
|
rm -rf "$assets_dir"
|
||||||
|
mkdir -p "$assets_dir"
|
||||||
|
|
||||||
|
found_apk=0
|
||||||
|
for apk in android/app/build/outputs/apk/release/*.apk; do
|
||||||
|
if [ ! -e "$apk" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
found_apk=1
|
||||||
|
base="$(basename "$apk")"
|
||||||
|
|
||||||
|
case "$base" in
|
||||||
|
app-*-release*.apk)
|
||||||
|
arch="${base#app-}"
|
||||||
|
arch="${arch%%-release*}"
|
||||||
|
;;
|
||||||
|
app-release*.apk)
|
||||||
|
arch="universal"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected APK filename: $base" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cp "$apk" "$assets_dir/kordophone-${arch}-${version}.apk"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found_apk" -ne 1 ]; then
|
||||||
|
echo "No release APKs were produced." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'RELEASE_VERSION=%s\n' "$version"
|
||||||
|
printf 'RELEASE_ASSETS_DIR=%s\n' "$assets_dir"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
name: Kordophone Android ${{ env.RELEASE_VERSION }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
files: |
|
||||||
|
${{ env.RELEASE_ASSETS_DIR }}/*.apk
|
||||||
2
android/.idea/gradle.xml
generated
2
android/.idea/gradle.xml
generated
@@ -5,7 +5,7 @@
|
|||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="jbr-17" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
1
android/.idea/vcs.xml
generated
1
android/.idea/vcs.xml
generated
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -24,7 +24,7 @@ data class ServerConfig(
|
|||||||
fun loadFromSettings(context: Context): ServerConfig {
|
fun loadFromSettings(context: Context): ServerConfig {
|
||||||
val prefs = getSharedPreferences(context)
|
val prefs = getSharedPreferences(context)
|
||||||
return ServerConfig(
|
return ServerConfig(
|
||||||
serverName = prefs.getString("serverName", null),
|
serverName = prefs.getString("serverName", null).normalizedBaseUrl(),
|
||||||
authentication = ServerAuthentication.loadFromEncryptedSettings(context)
|
authentication = ServerAuthentication.loadFromEncryptedSettings(context)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ data class ServerConfig(
|
|||||||
fun saveToSettings(context: Context) {
|
fun saveToSettings(context: Context) {
|
||||||
val prefs = getSharedPreferences(context)
|
val prefs = getSharedPreferences(context)
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString("serverName", serverName)
|
putString("serverName", serverName.normalizedBaseUrl())
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +45,11 @@ data class ServerConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String?.normalizedBaseUrl(): String? {
|
||||||
|
val value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
return if (value.endsWith("/")) value else "$value/"
|
||||||
|
}
|
||||||
|
|
||||||
data class ServerAuthentication(
|
data class ServerAuthentication(
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
@@ -101,7 +106,9 @@ class ServerConfigRepository @Inject constructor(
|
|||||||
|
|
||||||
fun applyConfig(applicator: ServerConfig.() -> Unit) {
|
fun applyConfig(applicator: ServerConfig.() -> Unit) {
|
||||||
val config = _serverConfig.value.copy()
|
val config = _serverConfig.value.copy()
|
||||||
_serverConfig.value = config.apply(applicator)
|
_serverConfig.value = config.apply(applicator).also {
|
||||||
|
it.serverName = it.serverName.normalizedBaseUrl()
|
||||||
|
}
|
||||||
_serverConfig.value.saveToSettings(context)
|
_serverConfig.value.saveToSettings(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ class APIClientFactory {
|
|||||||
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
|
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val normalizedServerString = serverString.ensureTrailingSlash()
|
||||||
|
|
||||||
// Try to parse server string
|
// Try to parse server string
|
||||||
val serverURL = HttpUrl.parse(serverString)
|
val serverURL = HttpUrl.parse(normalizedServerString)
|
||||||
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
|
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
|
||||||
|
|
||||||
return RetrofitAPIClient(serverURL.url(), authentication)
|
return RetrofitAPIClient(serverURL.url(), authentication)
|
||||||
@@ -113,6 +115,10 @@ class APIClientFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.ensureTrailingSlash(): String {
|
||||||
|
return if (endsWith("/")) this else "$this/"
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Is this a dumb idea?
|
// TODO: Is this a dumb idea?
|
||||||
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
|
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
|
||||||
enum class Issue {
|
enum class Issue {
|
||||||
@@ -215,4 +221,4 @@ fun URL.authenticatedWebSocketURL(serverPath: String, params: Map<String, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
return URL(requestURL.build().toString())
|
return URL(requestURL.build().toString())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ data class UploadAttachmentResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface APIInterface {
|
interface APIInterface {
|
||||||
@GET("/version")
|
@GET("version")
|
||||||
suspend fun getVersion(): ResponseBody
|
suspend fun getVersion(): ResponseBody
|
||||||
|
|
||||||
@GET("/conversations")
|
@GET("conversations")
|
||||||
suspend fun getConversations(): Response<List<Conversation>>
|
suspend fun getConversations(): Response<List<Conversation>>
|
||||||
|
|
||||||
@GET("/messages")
|
@GET("messages")
|
||||||
suspend fun getMessages(
|
suspend fun getMessages(
|
||||||
@Query("guid") conversationGUID: String,
|
@Query("guid") conversationGUID: String,
|
||||||
@Query("limit") limit: Int? = null,
|
@Query("limit") limit: Int? = null,
|
||||||
@@ -69,19 +69,19 @@ interface APIInterface {
|
|||||||
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
|
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
|
||||||
): Response<List<Message>>
|
): Response<List<Message>>
|
||||||
|
|
||||||
@POST("/sendMessage")
|
@POST("sendMessage")
|
||||||
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
|
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
|
||||||
|
|
||||||
@POST("/markConversation")
|
@POST("markConversation")
|
||||||
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
|
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
|
||||||
|
|
||||||
@GET("/attachment")
|
@GET("attachment")
|
||||||
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
|
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
|
||||||
|
|
||||||
@POST("/uploadAttachment")
|
@POST("uploadAttachment")
|
||||||
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
|
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
|
||||||
|
|
||||||
@POST("/authenticate")
|
@POST("authenticate")
|
||||||
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
|
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,4 +93,4 @@ fun <T> Response<T>.bodyOnSuccessOrThrow(): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw ResponseDecodeError(errorBody()!!)
|
throw ResponseDecodeError(errorBody()!!)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import net.buzzert.kordophone.backend.db.CachedChatDatabase
|
|||||||
import net.buzzert.kordophone.backend.model.Message
|
import net.buzzert.kordophone.backend.model.Message
|
||||||
import net.buzzert.kordophone.backend.model.OutgoingMessage
|
import net.buzzert.kordophone.backend.model.OutgoingMessage
|
||||||
import net.buzzert.kordophone.backend.server.APIClient
|
import net.buzzert.kordophone.backend.server.APIClient
|
||||||
|
import net.buzzert.kordophone.backend.server.APIClientFactory
|
||||||
import net.buzzert.kordophone.backend.server.APIInterface
|
import net.buzzert.kordophone.backend.server.APIInterface
|
||||||
import net.buzzert.kordophone.backend.server.Authentication
|
import net.buzzert.kordophone.backend.server.Authentication
|
||||||
import net.buzzert.kordophone.backend.server.ChatRepository
|
import net.buzzert.kordophone.backend.server.ChatRepository
|
||||||
@@ -38,6 +39,16 @@ class BackendTests {
|
|||||||
return Pair(repository, mockServer)
|
return Pair(repository, mockServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateClientAcceptsBaseUrlWithoutTrailingSlash() {
|
||||||
|
val client = APIClientFactory.createClient(
|
||||||
|
"https://example.com/api",
|
||||||
|
Authentication("test", "test")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(client.isConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetVersion() = runBlocking {
|
fun testGetVersion() = runBlocking {
|
||||||
val (repository, mockServer) = mockRepository()
|
val (repository, mockServer) = mockRepository()
|
||||||
@@ -342,4 +353,4 @@ class BackendTests {
|
|||||||
assertEquals(messagesToGenerate, allMessages.count())
|
assertEquals(messagesToGenerate, allMessages.count())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b
|
|||||||
// For each participant in a, check if there is a matching participant in b
|
// For each participant in a, check if there is a matching participant in b
|
||||||
a.iter().all(|a_participant| {
|
a.iter().all(|a_participant| {
|
||||||
b.iter().any(|b_participant| participants_equal_ignoring_id(a_participant, b_participant))
|
b.iter().any(|b_participant| participants_equal_ignoring_id(a_participant, b_participant))
|
||||||
}) &&
|
}) &&
|
||||||
// Also check the reverse to ensure no duplicates
|
// Also check the reverse to ensure no duplicates
|
||||||
b.iter().all(|b_participant| {
|
b.iter().all(|b_participant| {
|
||||||
a.iter().any(|a_participant| participants_equal_ignoring_id(b_participant, a_participant))
|
a.iter().any(|a_participant| participants_equal_ignoring_id(b_participant, a_participant))
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
|||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
|
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
|
||||||
OutgoingMessageTarget, ResolveHandleResponse, SendMessageResponse, UpdateItem,
|
UpdateItem,
|
||||||
},
|
},
|
||||||
APIInterface,
|
APIInterface,
|
||||||
};
|
};
|
||||||
@@ -65,15 +65,7 @@ impl std::error::Error for Error {
|
|||||||
|
|
||||||
impl std::fmt::Display for Error {
|
impl std::fmt::Display for Error {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
write!(f, "{:?}", self)
|
||||||
Error::ClientError(message) => write!(f, "{}", message),
|
|
||||||
Error::HTTPError(err) => write!(f, "HTTP transport error: {}", err),
|
|
||||||
Error::SerdeError(err) => write!(f, "JSON error: {}", err),
|
|
||||||
Error::DecodeError(message) => write!(f, "Decode error: {}", message),
|
|
||||||
Error::PongError(err) => write!(f, "WebSocket error: {}", err),
|
|
||||||
Error::URLError => write!(f, "Invalid URL"),
|
|
||||||
Error::Unauthorized => write!(f, "Unauthorized"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,17 +284,6 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_conversation(
|
|
||||||
&mut self,
|
|
||||||
conversation_id: &ConversationID,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
// SERVER JANK: This should be DELETE or POST, but it's GET for some reason.
|
|
||||||
let endpoint = format!("delete?guid={}", conversation_id);
|
|
||||||
self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_messages(
|
async fn get_messages(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: &ConversationID,
|
conversation_id: &ConversationID,
|
||||||
@@ -331,46 +312,16 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
outgoing_message: &OutgoingMessage,
|
outgoing_message: &OutgoingMessage,
|
||||||
) -> Result<SendMessageResponse, Self::Error> {
|
) -> Result<Message, Self::Error> {
|
||||||
match &outgoing_message.target {
|
let message: Message = self
|
||||||
OutgoingMessageTarget::Conversation(conversation_id) => {
|
|
||||||
log::debug!(
|
|
||||||
"Sending message to conversation {} (body_length={}, attachment_count={})",
|
|
||||||
conversation_id,
|
|
||||||
outgoing_message.text.len(),
|
|
||||||
outgoing_message.file_transfer_guids.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
OutgoingMessageTarget::Handles(handle_ids) => {
|
|
||||||
log::debug!(
|
|
||||||
"Sending message to resolved handles {:?} (body_length={}, attachment_count={})",
|
|
||||||
handle_ids,
|
|
||||||
outgoing_message.text.len(),
|
|
||||||
outgoing_message.file_transfer_guids.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let message: SendMessageResponse = self
|
|
||||||
.deserialized_response_with_body("sendMessage", Method::POST, || {
|
.deserialized_response_with_body("sendMessage", Method::POST, || {
|
||||||
Self::send_message_request_body(outgoing_message)
|
serde_json::to_string(&outgoing_message).unwrap().into()
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_handle(
|
|
||||||
&mut self,
|
|
||||||
handle_id: &str,
|
|
||||||
) -> Result<ResolveHandleResponse, Self::Error> {
|
|
||||||
log::debug!("Resolving handle {}", handle_id);
|
|
||||||
let endpoint = format!("resolveHandle?id={}", urlencoding::encode(handle_id));
|
|
||||||
let response: ResolveHandleResponse =
|
|
||||||
self.deserialized_response(&endpoint, Method::GET).await?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_attachment_data(
|
async fn fetch_attachment_data(
|
||||||
&mut self,
|
&mut self,
|
||||||
guid: &str,
|
guid: &str,
|
||||||
@@ -443,7 +394,8 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
|||||||
None => "updates".to_string(),
|
None => "updates".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let uri = self.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
|
let uri = self
|
||||||
|
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
log::debug!("Connecting to websocket: {:?}", uri);
|
log::debug!("Connecting to websocket: {:?}", uri);
|
||||||
@@ -474,20 +426,18 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
|||||||
|
|
||||||
log::debug!("Websocket request: {:?}", request);
|
log::debug!("Websocket request: {:?}", request);
|
||||||
|
|
||||||
let should_retry = true; // retry once after authenticating.
|
let mut should_retry = true; // retry once after authenticating.
|
||||||
match connect_async(request).await.map_err(Error::from) {
|
match connect_async(request).await.map_err(Error::from) {
|
||||||
Ok((socket, response)) => {
|
Ok((socket, response)) => {
|
||||||
log::debug!("Websocket connected: {:?}", response.status());
|
log::debug!("Websocket connected: {:?}", response.status());
|
||||||
break Ok(WebsocketEventSocket::new(socket));
|
break Ok(WebsocketEventSocket::new(socket))
|
||||||
}
|
}
|
||||||
Err(e) => match &e {
|
Err(e) => match &e {
|
||||||
Error::ClientError(ce) => match ce.as_str() {
|
Error::ClientError(ce) => match ce.as_str() {
|
||||||
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
|
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Some(credentials) = &self.auth_store.get_credentials().await {
|
if let Some(credentials) = &self.auth_store.get_credentials().await {
|
||||||
log::warn!(
|
log::warn!("Websocket connection failed, attempting to authenticate");
|
||||||
"Websocket connection failed, attempting to authenticate"
|
|
||||||
);
|
|
||||||
let new_token = self.authenticate(credentials.clone()).await?;
|
let new_token = self.authenticate(credentials.clone()).await?;
|
||||||
self.auth_store.set_token(new_token.to_string()).await;
|
self.auth_store.set_token(new_token.to_string()).await;
|
||||||
|
|
||||||
@@ -523,44 +473,16 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
.build();
|
.build();
|
||||||
let client = Client::builder().build::<_, Body>(https);
|
let client = Client::builder().build::<_, Body>(https);
|
||||||
|
|
||||||
HTTPAPIClient {
|
HTTPAPIClient { base_url, auth_store, client }
|
||||||
base_url,
|
|
||||||
auth_store,
|
|
||||||
client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_message_request_body(outgoing_message: &OutgoingMessage) -> Body {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct SendMessageRequest<'a> {
|
|
||||||
#[serde(rename = "body")]
|
|
||||||
text: &'a str,
|
|
||||||
#[serde(rename = "guid", skip_serializing_if = "Option::is_none")]
|
|
||||||
conversation_id: Option<&'a ConversationID>,
|
|
||||||
#[serde(rename = "handleIDs", skip_serializing_if = "Option::is_none")]
|
|
||||||
handle_ids: Option<&'a [String]>,
|
|
||||||
#[serde(rename = "fileTransferGUIDs")]
|
|
||||||
file_transfer_guids: &'a Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let (conversation_id, handle_ids) = match &outgoing_message.target {
|
|
||||||
OutgoingMessageTarget::Conversation(conversation_id) => (Some(conversation_id), None),
|
|
||||||
OutgoingMessageTarget::Handles(handle_ids) => (None, Some(handle_ids.as_slice())),
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::to_string(&SendMessageRequest {
|
|
||||||
text: &outgoing_message.text,
|
|
||||||
conversation_id,
|
|
||||||
handle_ids,
|
|
||||||
file_transfer_guids: &outgoing_message.file_transfer_guids,
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> {
|
fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> {
|
||||||
let mut parts = self.base_url.clone().into_parts();
|
let mut parts = self.base_url.clone().into_parts();
|
||||||
let root_path: PathBuf = parts.path_and_query.ok_or(Error::URLError)?.path().into();
|
let root_path: PathBuf = parts
|
||||||
|
.path_and_query
|
||||||
|
.ok_or(Error::URLError)?
|
||||||
|
.path()
|
||||||
|
.into();
|
||||||
|
|
||||||
let path = root_path.join(endpoint);
|
let path = root_path.join(endpoint);
|
||||||
let path_str = path.to_str().ok_or(Error::URLError)?;
|
let path_str = path.to_str().ok_or(Error::URLError)?;
|
||||||
@@ -581,18 +503,6 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_transport_error(method: &Method, target: &str, err: &hyper::Error) {
|
|
||||||
log::error!("HTTP transport error for {} {}: {}", method, target, err);
|
|
||||||
|
|
||||||
if format!("{:?}", err).contains("IncompleteMessage") {
|
|
||||||
log::error!(
|
|
||||||
"The server closed the connection before a complete response was received for {} {}.",
|
|
||||||
method,
|
|
||||||
target
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn deserialized_response<T: DeserializeOwned>(
|
async fn deserialized_response<T: DeserializeOwned>(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
@@ -626,26 +536,15 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
{
|
{
|
||||||
let response = self
|
let response = self
|
||||||
.response_with_body_retry(endpoint, method.clone(), body_fn, retry_auth)
|
.response_with_body_retry(endpoint, method, body_fn, retry_auth)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Read and parse response body
|
// Read and parse response body
|
||||||
let body = match hyper::body::to_bytes(response.into_body()).await {
|
let body = hyper::body::to_bytes(response.into_body()).await?;
|
||||||
Ok(body) => body,
|
|
||||||
Err(err) => {
|
|
||||||
Self::log_transport_error(&method, endpoint, &err);
|
|
||||||
return Err(Error::HTTPError(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let parsed: T = match serde_json::from_slice(&body) {
|
let parsed: T = match serde_json::from_slice(&body) {
|
||||||
Ok(result) => Ok(result),
|
Ok(result) => Ok(result),
|
||||||
Err(json_err) => {
|
Err(json_err) => {
|
||||||
log::error!(
|
log::error!("Error deserializing JSON: {:?}", json_err);
|
||||||
"Error deserializing JSON for {} {}: {:?}",
|
|
||||||
method,
|
|
||||||
endpoint,
|
|
||||||
json_err
|
|
||||||
);
|
|
||||||
log::error!("Body: {:?}", String::from_utf8_lossy(&body));
|
log::error!("Body: {:?}", String::from_utf8_lossy(&body));
|
||||||
|
|
||||||
// If JSON deserialization fails, try to interpret it as plain text
|
// If JSON deserialization fails, try to interpret it as plain text
|
||||||
@@ -668,8 +567,7 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
|
|
||||||
let uri = self.uri_for_endpoint(endpoint, None)?;
|
let uri = self.uri_for_endpoint(endpoint, None)?;
|
||||||
let uri_string = uri.to_string();
|
log::debug!("Requesting {:?} {:?}", method, uri);
|
||||||
log::debug!("Requesting {} {}", method, uri_string);
|
|
||||||
|
|
||||||
let mut build_request = |auth: &Option<String>| {
|
let mut build_request = |auth: &Option<String>| {
|
||||||
let body = body_fn();
|
let body = body_fn();
|
||||||
@@ -683,24 +581,13 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
|
|
||||||
log::trace!("Obtaining token from auth store");
|
log::trace!("Obtaining token from auth store");
|
||||||
let token = self.auth_store.get_token().await;
|
let token = self.auth_store.get_token().await;
|
||||||
log::trace!("Token present: {}", token.is_some());
|
log::trace!("Token: {:?}", token);
|
||||||
|
|
||||||
let request = build_request(&token);
|
let request = build_request(&token);
|
||||||
log::trace!(
|
log::trace!("Request: {:?}. Sending request...", request);
|
||||||
"Sending request: method={} uri={} authenticated={}",
|
|
||||||
method,
|
|
||||||
uri_string,
|
|
||||||
token.is_some()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut response = match self.client.request(request).await {
|
let mut response = self.client.request(request).await?;
|
||||||
Ok(response) => response,
|
log::debug!("-> Response: {:}", response.status());
|
||||||
Err(err) => {
|
|
||||||
Self::log_transport_error(&method, &uri_string, &err);
|
|
||||||
return Err(Error::HTTPError(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log::debug!("-> Response: {}", response.status());
|
|
||||||
|
|
||||||
match response.status() {
|
match response.status() {
|
||||||
StatusCode::OK => { /* cool */ }
|
StatusCode::OK => { /* cool */ }
|
||||||
@@ -719,19 +606,7 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
|||||||
let new_token = self.authenticate(credentials.clone()).await?;
|
let new_token = self.authenticate(credentials.clone()).await?;
|
||||||
|
|
||||||
let request = build_request(&Some(new_token.to_string()));
|
let request = build_request(&Some(new_token.to_string()));
|
||||||
log::trace!(
|
response = self.client.request(request).await?;
|
||||||
"Retrying request after authentication: method={} uri={} authenticated=true",
|
|
||||||
method,
|
|
||||||
uri_string
|
|
||||||
);
|
|
||||||
response = match self.client.request(request).await {
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(err) => {
|
|
||||||
Self::log_transport_error(&method, &uri_string, &err);
|
|
||||||
return Err(Error::HTTPError(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log::debug!("-> Retry response: {}", response.status());
|
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::ClientError(
|
return Err(Error::ClientError(
|
||||||
"Unauthorized, no credentials provided".into(),
|
"Unauthorized, no credentials provided".into(),
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
pub use crate::model::{
|
pub use crate::model::{Conversation, ConversationID, Message, MessageID, OutgoingMessage};
|
||||||
Conversation, ConversationID, Message, MessageID, OutgoingMessage, ResolveHandleResponse,
|
|
||||||
SendMessageResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -45,13 +42,7 @@ pub trait APIInterface {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
outgoing_message: &OutgoingMessage,
|
outgoing_message: &OutgoingMessage,
|
||||||
) -> Result<SendMessageResponse, Self::Error>;
|
) -> Result<Message, Self::Error>;
|
||||||
|
|
||||||
// (GET) /resolveHandle
|
|
||||||
async fn resolve_handle(
|
|
||||||
&mut self,
|
|
||||||
handle_id: &str,
|
|
||||||
) -> Result<ResolveHandleResponse, Self::Error>;
|
|
||||||
|
|
||||||
// (GET) /attachment
|
// (GET) /attachment
|
||||||
async fn fetch_attachment_data(
|
async fn fetch_attachment_data(
|
||||||
@@ -79,12 +70,6 @@ pub trait APIInterface {
|
|||||||
conversation_id: &ConversationID,
|
conversation_id: &ConversationID,
|
||||||
) -> Result<(), Self::Error>;
|
) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
// (GET) /delete
|
|
||||||
async fn delete_conversation(
|
|
||||||
&mut self,
|
|
||||||
conversation_id: &ConversationID,
|
|
||||||
) -> Result<(), Self::Error>;
|
|
||||||
|
|
||||||
// (WS) /updates
|
// (WS) /updates
|
||||||
async fn open_event_socket(
|
async fn open_event_socket(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use super::conversation::ConversationID;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ResolvedHandle {
|
|
||||||
pub id: String,
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum HandleResolutionStatus {
|
|
||||||
Valid,
|
|
||||||
Invalid,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ResolveHandleResponse {
|
|
||||||
#[serde(rename = "resolvedHandle")]
|
|
||||||
pub resolved_handle: ResolvedHandle,
|
|
||||||
|
|
||||||
pub status: HandleResolutionStatus,
|
|
||||||
|
|
||||||
#[serde(rename = "existingChat")]
|
|
||||||
pub existing_chat: Option<ConversationID>,
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod handle;
|
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod outgoing_message;
|
pub mod outgoing_message;
|
||||||
pub mod send_message_response;
|
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
@@ -12,15 +10,8 @@ pub use conversation::ConversationID;
|
|||||||
pub use message::Message;
|
pub use message::Message;
|
||||||
pub use message::MessageID;
|
pub use message::MessageID;
|
||||||
|
|
||||||
pub use handle::HandleResolutionStatus;
|
|
||||||
pub use handle::ResolveHandleResponse;
|
|
||||||
pub use handle::ResolvedHandle;
|
|
||||||
|
|
||||||
pub use outgoing_message::OutgoingMessage;
|
pub use outgoing_message::OutgoingMessage;
|
||||||
pub use outgoing_message::OutgoingMessageBuilder;
|
pub use outgoing_message::OutgoingMessageBuilder;
|
||||||
pub use outgoing_message::OutgoingMessageTarget;
|
|
||||||
|
|
||||||
pub use send_message_response::SendMessageResponse;
|
|
||||||
|
|
||||||
pub use update::UpdateItem;
|
pub use update::UpdateItem;
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
use super::conversation::ConversationID;
|
use super::conversation::ConversationID;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub enum OutgoingMessageTarget {
|
|
||||||
Conversation(ConversationID),
|
|
||||||
Handles(Vec<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OutgoingMessage {
|
pub struct OutgoingMessage {
|
||||||
|
#[serde(skip)]
|
||||||
pub guid: Uuid,
|
pub guid: Uuid,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
pub date: NaiveDateTime,
|
pub date: NaiveDateTime,
|
||||||
|
|
||||||
|
#[serde(rename = "body")]
|
||||||
pub text: String,
|
pub text: String,
|
||||||
|
|
||||||
pub target: OutgoingMessageTarget,
|
#[serde(rename = "guid")]
|
||||||
|
pub conversation_id: ConversationID,
|
||||||
|
|
||||||
|
#[serde(rename = "fileTransferGUIDs")]
|
||||||
pub file_transfer_guids: Vec<String>,
|
pub file_transfer_guids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,27 +25,13 @@ impl OutgoingMessage {
|
|||||||
pub fn builder() -> OutgoingMessageBuilder {
|
pub fn builder() -> OutgoingMessageBuilder {
|
||||||
OutgoingMessageBuilder::new()
|
OutgoingMessageBuilder::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn conversation_id(&self) -> Option<&ConversationID> {
|
|
||||||
match &self.target {
|
|
||||||
OutgoingMessageTarget::Conversation(conversation_id) => Some(conversation_id),
|
|
||||||
OutgoingMessageTarget::Handles(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_ids(&self) -> Option<&[String]> {
|
|
||||||
match &self.target {
|
|
||||||
OutgoingMessageTarget::Conversation(_) => None,
|
|
||||||
OutgoingMessageTarget::Handles(handle_ids) => Some(handle_ids.as_slice()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct OutgoingMessageBuilder {
|
pub struct OutgoingMessageBuilder {
|
||||||
guid: Option<Uuid>,
|
guid: Option<Uuid>,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
target: Option<OutgoingMessageTarget>,
|
conversation_id: Option<ConversationID>,
|
||||||
file_transfer_guids: Option<Vec<String>>,
|
file_transfer_guids: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +50,8 @@ impl OutgoingMessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn target(mut self, target: OutgoingMessageTarget) -> Self {
|
|
||||||
self.target = Some(target);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn conversation_id(mut self, conversation_id: ConversationID) -> Self {
|
pub fn conversation_id(mut self, conversation_id: ConversationID) -> Self {
|
||||||
self.target = Some(OutgoingMessageTarget::Conversation(conversation_id));
|
self.conversation_id = Some(conversation_id);
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_ids(mut self, handle_ids: Vec<String>) -> Self {
|
|
||||||
self.target = Some(OutgoingMessageTarget::Handles(handle_ids));
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +64,7 @@ impl OutgoingMessageBuilder {
|
|||||||
OutgoingMessage {
|
OutgoingMessage {
|
||||||
guid: self.guid.unwrap_or_else(Uuid::new_v4),
|
guid: self.guid.unwrap_or_else(Uuid::new_v4),
|
||||||
text: self.text.unwrap(),
|
text: self.text.unwrap(),
|
||||||
target: self.target.unwrap(),
|
conversation_id: self.conversation_id.unwrap(),
|
||||||
file_transfer_guids: self.file_transfer_guids.unwrap_or_default(),
|
file_transfer_guids: self.file_transfer_guids.unwrap_or_default(),
|
||||||
date: chrono::Utc::now().naive_utc(),
|
date: chrono::Utc::now().naive_utc(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{conversation::ConversationID, message::Message};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct SendMessageResponse {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub message: Message,
|
|
||||||
|
|
||||||
#[serde(rename = "conversationGUID")]
|
|
||||||
pub conversation_id: Option<ConversationID>,
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ use self::test_client::TestClient;
|
|||||||
use crate::APIInterface;
|
use crate::APIInterface;
|
||||||
|
|
||||||
pub mod api_interface {
|
pub mod api_interface {
|
||||||
use crate::model::{Conversation, HandleResolutionStatus, OutgoingMessage};
|
use crate::model::Conversation;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -28,42 +28,4 @@ pub mod api_interface {
|
|||||||
assert_eq!(conversations.len(), 1);
|
assert_eq!(conversations.len(), 1);
|
||||||
assert_eq!(conversations[0].display_name, test_convo.display_name);
|
assert_eq!(conversations[0].display_name, test_convo.display_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_handle() {
|
|
||||||
let mut client = TestClient::new();
|
|
||||||
|
|
||||||
let resolved = client.resolve_handle("user@example.com").await.unwrap();
|
|
||||||
assert_eq!(resolved.resolved_handle.id, "user@example.com");
|
|
||||||
assert_eq!(resolved.status, HandleResolutionStatus::Valid);
|
|
||||||
assert_eq!(resolved.existing_chat, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_send_message_with_handles() {
|
|
||||||
let mut client = TestClient::new();
|
|
||||||
|
|
||||||
let outgoing_message = OutgoingMessage::builder()
|
|
||||||
.text("hello".to_string())
|
|
||||||
.handle_ids(vec!["user@example.com".to_string()])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let sent = client.send_message(&outgoing_message).await.unwrap();
|
|
||||||
assert_eq!(sent.message.text, "hello");
|
|
||||||
assert_eq!(sent.conversation_id, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_conversation() {
|
|
||||||
let mut client = TestClient::new();
|
|
||||||
|
|
||||||
let test_convo = Conversation::builder().display_name("Delete Me").build();
|
|
||||||
|
|
||||||
client.conversations.push(test_convo.clone());
|
|
||||||
|
|
||||||
client.delete_conversation(&test_convo.guid).await.unwrap();
|
|
||||||
|
|
||||||
let conversations = client.get_conversations().await.unwrap();
|
|
||||||
assert!(conversations.is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,14 @@ use crate::{
|
|||||||
api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate},
|
api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate},
|
||||||
api::http_client::Credentials,
|
api::http_client::Credentials,
|
||||||
model::{
|
model::{
|
||||||
Conversation, ConversationID, Event, HandleResolutionStatus, JwtToken, Message, MessageID,
|
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
|
||||||
OutgoingMessage, OutgoingMessageTarget, ResolveHandleResponse, ResolvedHandle,
|
UpdateItem,
|
||||||
SendMessageResponse,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_util::sink::drain;
|
|
||||||
use futures_util::stream::BoxStream;
|
use futures_util::stream::BoxStream;
|
||||||
use futures_util::Sink;
|
use futures_util::Sink;
|
||||||
use futures_util::SinkExt;
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
pub struct TestClient {
|
pub struct TestClient {
|
||||||
@@ -66,18 +63,13 @@ impl EventSocket for TestEventSocket {
|
|||||||
impl Sink<SinkMessage, Error = Self::Error>,
|
impl Sink<SinkMessage, Error = Self::Error>,
|
||||||
) {
|
) {
|
||||||
(
|
(
|
||||||
futures_util::stream::iter(
|
futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed(),
|
||||||
self.events
|
futures_util::sink::sink(),
|
||||||
.into_iter()
|
|
||||||
.map(|event| Ok(SocketEvent::Update(event))),
|
|
||||||
)
|
|
||||||
.boxed(),
|
|
||||||
drain().sink_map_err(|err| match err {}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn raw_updates(self) -> Self::UpdateStream {
|
async fn raw_updates(self) -> Self::UpdateStream {
|
||||||
let results: Vec<Result<SocketUpdate, TestError>> = vec![];
|
let results: Vec<Result<Vec<UpdateItem>, TestError>> = vec![];
|
||||||
futures_util::stream::iter(results.into_iter()).boxed()
|
futures_util::stream::iter(results.into_iter()).boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,9 +94,9 @@ impl APIInterface for TestClient {
|
|||||||
async fn get_messages(
|
async fn get_messages(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: &ConversationID,
|
conversation_id: &ConversationID,
|
||||||
_limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
_before: Option<MessageID>,
|
before: Option<MessageID>,
|
||||||
_after: Option<MessageID>,
|
after: Option<MessageID>,
|
||||||
) -> Result<Vec<Message>, Self::Error> {
|
) -> Result<Vec<Message>, Self::Error> {
|
||||||
if let Some(messages) = self.messages.get(conversation_id) {
|
if let Some(messages) = self.messages.get(conversation_id) {
|
||||||
return Ok(messages.clone());
|
return Ok(messages.clone());
|
||||||
@@ -116,42 +108,18 @@ impl APIInterface for TestClient {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
outgoing_message: &OutgoingMessage,
|
outgoing_message: &OutgoingMessage,
|
||||||
) -> Result<SendMessageResponse, Self::Error> {
|
) -> Result<Message, Self::Error> {
|
||||||
let message = Message::builder()
|
let message = Message::builder()
|
||||||
.guid(Uuid::new_v4().to_string())
|
.guid(Uuid::new_v4().to_string())
|
||||||
.text(outgoing_message.text.clone())
|
.text(outgoing_message.text.clone())
|
||||||
.date(OffsetDateTime::now_utc())
|
.date(OffsetDateTime::now_utc())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let conversation_id = match &outgoing_message.target {
|
self.messages
|
||||||
OutgoingMessageTarget::Conversation(conversation_id) => {
|
.entry(outgoing_message.conversation_id.clone())
|
||||||
self.messages
|
.or_insert(vec![])
|
||||||
.entry(conversation_id.clone())
|
.push(message.clone());
|
||||||
.or_insert(vec![])
|
Ok(message)
|
||||||
.push(message.clone());
|
|
||||||
None
|
|
||||||
}
|
|
||||||
OutgoingMessageTarget::Handles(_) => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(SendMessageResponse {
|
|
||||||
message,
|
|
||||||
conversation_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_handle(
|
|
||||||
&mut self,
|
|
||||||
handle_id: &str,
|
|
||||||
) -> Result<ResolveHandleResponse, Self::Error> {
|
|
||||||
Ok(ResolveHandleResponse {
|
|
||||||
resolved_handle: ResolvedHandle {
|
|
||||||
id: handle_id.to_string(),
|
|
||||||
name: None,
|
|
||||||
},
|
|
||||||
status: HandleResolutionStatus::Valid,
|
|
||||||
existing_chat: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn open_event_socket(
|
async fn open_event_socket(
|
||||||
@@ -163,17 +131,17 @@ impl APIInterface for TestClient {
|
|||||||
|
|
||||||
async fn fetch_attachment_data(
|
async fn fetch_attachment_data(
|
||||||
&mut self,
|
&mut self,
|
||||||
_guid: &str,
|
guid: &str,
|
||||||
_preview: bool,
|
preview: bool,
|
||||||
) -> Result<Self::ResponseStream, Self::Error> {
|
) -> Result<Self::ResponseStream, Self::Error> {
|
||||||
Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed())
|
Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_attachment<R>(
|
async fn upload_attachment<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
_data: tokio::io::BufReader<R>,
|
data: tokio::io::BufReader<R>,
|
||||||
_filename: &str,
|
filename: &str,
|
||||||
_size: u64,
|
size: u64,
|
||||||
) -> Result<String, Self::Error>
|
) -> Result<String, Self::Error>
|
||||||
where
|
where
|
||||||
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
|
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
|
||||||
@@ -182,24 +150,9 @@ impl APIInterface for TestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_conversation_as_read(
|
async fn mark_conversation_as_read(
|
||||||
&mut self,
|
|
||||||
_conversation_id: &ConversationID,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_conversation(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: &ConversationID,
|
conversation_id: &ConversationID,
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
let previous_len = self.conversations.len();
|
|
||||||
self.conversations.retain(|c| &c.guid != conversation_id);
|
|
||||||
self.messages.remove(conversation_id);
|
|
||||||
|
|
||||||
if self.conversations.len() == previous_len {
|
|
||||||
return Err(TestError::ConversationNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ fn main() {
|
|||||||
|
|
||||||
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ mod platform;
|
|||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
||||||
|
|
||||||
|
|||||||
@@ -114,23 +114,11 @@ impl DaemonClient for DBusClient {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reply(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||||
let attachment_guids: Vec<&str> = vec![];
|
let attachment_guids: Vec<&str> = vec![];
|
||||||
let outgoing_id =
|
let outgoing_id = KordophoneRepository::send_message(
|
||||||
KordophoneRepository::reply(&self.proxy(), &conversation_id, &text, attachment_guids)?;
|
|
||||||
Ok(Some(outgoing_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_conversation(
|
|
||||||
&mut self,
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
text: String,
|
|
||||||
) -> Result<Option<String>> {
|
|
||||||
let attachment_guids: Vec<&str> = vec![];
|
|
||||||
let handle_ids: Vec<&str> = handle_ids.iter().map(String::as_str).collect();
|
|
||||||
let outgoing_id = KordophoneRepository::new_conversation(
|
|
||||||
&self.proxy(),
|
&self.proxy(),
|
||||||
handle_ids,
|
&conversation_id,
|
||||||
&text,
|
&text,
|
||||||
attachment_guids,
|
attachment_guids,
|
||||||
)?;
|
)?;
|
||||||
@@ -198,3 +186,4 @@ impl DaemonClient for DBusClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,14 +95,8 @@ impl XpcClient {
|
|||||||
impl DaemonClient for XpcClient {
|
impl DaemonClient for XpcClient {
|
||||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||||
let mut args = HashMap::new();
|
let mut args = HashMap::new();
|
||||||
args.insert(
|
args.insert(Self::key("limit"), Message::String(Self::key(&limit.to_string())));
|
||||||
Self::key("limit"),
|
args.insert(Self::key("offset"), Message::String(Self::key(&offset.to_string())));
|
||||||
Message::String(Self::key(&limit.to_string())),
|
|
||||||
);
|
|
||||||
args.insert(
|
|
||||||
Self::key("offset"),
|
|
||||||
Message::String(Self::key(&offset.to_string())),
|
|
||||||
);
|
|
||||||
|
|
||||||
let reply = self
|
let reply = self
|
||||||
.transport
|
.transport
|
||||||
@@ -118,9 +112,7 @@ impl DaemonClient for XpcClient {
|
|||||||
|
|
||||||
let mut conversations = Vec::new();
|
let mut conversations = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
let Message::Dictionary(conv) = item else {
|
let Message::Dictionary(conv) = item else { continue };
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let id = Self::get_string(conv, "guid").unwrap_or_default();
|
let id = Self::get_string(conv, "guid").unwrap_or_default();
|
||||||
let display_name = Self::get_string(conv, "display_name").unwrap_or_default();
|
let display_name = Self::get_string(conv, "display_name").unwrap_or_default();
|
||||||
let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default();
|
let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default();
|
||||||
@@ -170,10 +162,7 @@ impl DaemonClient for XpcClient {
|
|||||||
Message::String(Self::key(&conversation_id)),
|
Message::String(Self::key(&conversation_id)),
|
||||||
);
|
);
|
||||||
if let Some(last) = last_message_id {
|
if let Some(last) = last_message_id {
|
||||||
args.insert(
|
args.insert(Self::key("last_message_id"), Message::String(Self::key(&last)));
|
||||||
Self::key("last_message_id"),
|
|
||||||
Message::String(Self::key(&last)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply = self
|
let reply = self
|
||||||
@@ -189,9 +178,7 @@ impl DaemonClient for XpcClient {
|
|||||||
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
let Message::Dictionary(msg) = item else {
|
let Message::Dictionary(msg) = item else { continue };
|
||||||
continue;
|
|
||||||
};
|
|
||||||
messages.push(ChatMessage {
|
messages.push(ChatMessage {
|
||||||
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
||||||
text: Self::get_string(msg, "text").unwrap_or_default(),
|
text: Self::get_string(msg, "text").unwrap_or_default(),
|
||||||
@@ -201,7 +188,7 @@ impl DaemonClient for XpcClient {
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reply(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||||
let mut args = HashMap::new();
|
let mut args = HashMap::new();
|
||||||
args.insert(
|
args.insert(
|
||||||
Self::key("conversation_id"),
|
Self::key("conversation_id"),
|
||||||
@@ -211,34 +198,7 @@ impl DaemonClient for XpcClient {
|
|||||||
|
|
||||||
let reply = self
|
let reply = self
|
||||||
.transport
|
.transport
|
||||||
.send_with_reply(Self::request("Reply", Some(args)));
|
.send_with_reply(Self::request("SendMessage", Some(args)));
|
||||||
let Message::Dictionary(map) = reply else {
|
|
||||||
anyhow::bail!("Unexpected send response");
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self::get_string(&map, "uuid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_conversation(
|
|
||||||
&mut self,
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
text: String,
|
|
||||||
) -> Result<Option<String>> {
|
|
||||||
let mut args = HashMap::new();
|
|
||||||
args.insert(
|
|
||||||
Self::key("handle_ids"),
|
|
||||||
Message::Array(
|
|
||||||
handle_ids
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle_id| Message::String(Self::key(&handle_id)))
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
args.insert(Self::key("text"), Message::String(Self::key(&text)));
|
|
||||||
|
|
||||||
let reply = self
|
|
||||||
.transport
|
|
||||||
.send_with_reply(Self::request("NewConversation", Some(args)));
|
|
||||||
let Message::Dictionary(map) = reply else {
|
let Message::Dictionary(map) = reply else {
|
||||||
anyhow::bail!("Unexpected send response");
|
anyhow::bail!("Unexpected send response");
|
||||||
};
|
};
|
||||||
@@ -270,3 +230,4 @@ impl DaemonClient for XpcClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ pub(crate) fn new_daemon_client() -> Result<Box<dyn DaemonClient>> {
|
|||||||
anyhow::bail!("Unsupported platform")
|
anyhow::bail!("Unsupported platform")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,23 +21,10 @@ pub struct ChatMessage {
|
|||||||
|
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
RefreshConversations,
|
RefreshConversations,
|
||||||
RefreshMessages {
|
RefreshMessages { conversation_id: String },
|
||||||
conversation_id: String,
|
SendMessage { conversation_id: String, text: String },
|
||||||
},
|
MarkRead { conversation_id: String },
|
||||||
Reply {
|
SyncConversation { conversation_id: String },
|
||||||
conversation_id: String,
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
NewConversation {
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
MarkRead {
|
|
||||||
conversation_id: String,
|
|
||||||
},
|
|
||||||
SyncConversation {
|
|
||||||
conversation_id: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
@@ -46,18 +33,14 @@ pub enum Event {
|
|||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
},
|
},
|
||||||
MessageQueued {
|
MessageSent {
|
||||||
conversation_id: Option<String>,
|
conversation_id: String,
|
||||||
outgoing_id: Option<String>,
|
outgoing_id: Option<String>,
|
||||||
},
|
},
|
||||||
MarkedRead,
|
MarkedRead,
|
||||||
ConversationSyncTriggered {
|
ConversationSyncTriggered { conversation_id: String },
|
||||||
conversation_id: String,
|
|
||||||
},
|
|
||||||
ConversationsUpdated,
|
ConversationsUpdated,
|
||||||
MessagesUpdated {
|
MessagesUpdated { conversation_id: String },
|
||||||
conversation_id: String,
|
|
||||||
},
|
|
||||||
UpdateStreamReconnected,
|
UpdateStreamReconnected,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -76,37 +59,29 @@ pub fn spawn_worker(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
||||||
let _ = event_tx.send(Event::Error(format!(
|
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
|
||||||
"Failed to install daemon signals: {e}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
Ok(req) => {
|
Ok(req) => {
|
||||||
let res = match req {
|
let res = match req {
|
||||||
Request::RefreshConversations => {
|
Request::RefreshConversations => client
|
||||||
client.get_conversations(200, 0).map(Event::Conversations)
|
.get_conversations(200, 0)
|
||||||
}
|
.map(Event::Conversations),
|
||||||
Request::RefreshMessages { conversation_id } => client
|
Request::RefreshMessages { conversation_id } => client
|
||||||
.get_messages(conversation_id.clone(), None)
|
.get_messages(conversation_id.clone(), None)
|
||||||
.map(|messages| Event::Messages {
|
.map(|messages| Event::Messages {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
messages,
|
messages,
|
||||||
}),
|
}),
|
||||||
Request::Reply {
|
Request::SendMessage {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
text,
|
text,
|
||||||
} => client
|
} => client
|
||||||
.reply(conversation_id.clone(), text)
|
.send_message(conversation_id.clone(), text)
|
||||||
.map(|outgoing_id| Event::MessageQueued {
|
.map(|outgoing_id| Event::MessageSent {
|
||||||
conversation_id: Some(conversation_id),
|
conversation_id,
|
||||||
outgoing_id,
|
|
||||||
}),
|
|
||||||
Request::NewConversation { handle_ids, text } => client
|
|
||||||
.new_conversation(handle_ids, text)
|
|
||||||
.map(|outgoing_id| Event::MessageQueued {
|
|
||||||
conversation_id: None,
|
|
||||||
outgoing_id,
|
outgoing_id,
|
||||||
}),
|
}),
|
||||||
Request::MarkRead { conversation_id } => client
|
Request::MarkRead { conversation_id } => client
|
||||||
@@ -144,9 +119,7 @@ pub(crate) trait DaemonClient {
|
|||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
last_message_id: Option<String>,
|
last_message_id: Option<String>,
|
||||||
) -> Result<Vec<ChatMessage>>;
|
) -> Result<Vec<ChatMessage>>;
|
||||||
fn reply(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
||||||
fn new_conversation(&mut self, handle_ids: Vec<String>, text: String)
|
|
||||||
-> Result<Option<String>>;
|
|
||||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
||||||
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
||||||
@@ -157,3 +130,4 @@ pub(crate) trait DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ fn main() {
|
|||||||
|
|
||||||
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
</arg>
|
</arg>
|
||||||
</method>
|
</method>
|
||||||
|
|
||||||
<method name="Reply">
|
<method name="SendMessage">
|
||||||
<arg type="s" name="conversation_id" direction="in"/>
|
<arg type="s" name="conversation_id" direction="in"/>
|
||||||
<arg type="s" name="text" direction="in"/>
|
<arg type="s" name="text" direction="in"/>
|
||||||
<arg type="as" name="attachment_guids" direction="in"/>
|
<arg type="as" name="attachment_guids" direction="in"/>
|
||||||
@@ -91,28 +91,9 @@
|
|||||||
<arg type="s" name="outgoing_message_id" direction="out"/>
|
<arg type="s" name="outgoing_message_id" direction="out"/>
|
||||||
|
|
||||||
<annotation name="org.freedesktop.DBus.DocString"
|
<annotation name="org.freedesktop.DBus.DocString"
|
||||||
value="Replies to an existing conversation. Returns the outgoing message ID.
|
value="Sends a message to the server. Returns the outgoing message ID.
|
||||||
Arguments:
|
Arguments:
|
||||||
- conversation_id: The ID of the conversation to reply to.
|
- conversation_id: The ID of the conversation to send the message to.
|
||||||
- text: The text of the message to send.
|
|
||||||
- attachment_guids: The GUIDs of the attachments to send.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- outgoing_message_id: The ID of the outgoing message.
|
|
||||||
"/>
|
|
||||||
</method>
|
|
||||||
|
|
||||||
<method name="NewConversation">
|
|
||||||
<arg type="as" name="handle_ids" direction="in"/>
|
|
||||||
<arg type="s" name="text" direction="in"/>
|
|
||||||
<arg type="as" name="attachment_guids" direction="in"/>
|
|
||||||
|
|
||||||
<arg type="s" name="outgoing_message_id" direction="out"/>
|
|
||||||
|
|
||||||
<annotation name="org.freedesktop.DBus.DocString"
|
|
||||||
value="Sends a message to a new conversation identified by resolved handles.
|
|
||||||
Arguments:
|
|
||||||
- handle_ids: The resolved handles for the new conversation.
|
|
||||||
- text: The text of the message to send.
|
- text: The text of the message to send.
|
||||||
- attachment_guids: The GUIDs of the attachments to send.
|
- attachment_guids: The GUIDs of the attachments to send.
|
||||||
|
|
||||||
|
|||||||
@@ -53,21 +53,13 @@ pub enum Event {
|
|||||||
/// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned.
|
/// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned.
|
||||||
GetMessages(String, Option<String>, Reply<Vec<Message>>),
|
GetMessages(String, Option<String>, Reply<Vec<Message>>),
|
||||||
|
|
||||||
/// Enqueues a reply to an existing conversation.
|
/// Enqueues a message to be sent to the server.
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
/// - conversation_id: The ID of the conversation to send the message to.
|
/// - conversation_id: The ID of the conversation to send the message to.
|
||||||
/// - text: The text of the message to send.
|
/// - text: The text of the message to send.
|
||||||
/// - attachment_guids: The GUIDs of the attachments to send.
|
/// - attachment_guids: The GUIDs of the attachments to send.
|
||||||
/// - reply: The outgoing message ID (not the server-assigned message ID).
|
/// - reply: The outgoing message ID (not the server-assigned message ID).
|
||||||
Reply(String, String, Vec<String>, Reply<Uuid>),
|
SendMessage(String, String, Vec<String>, Reply<Uuid>),
|
||||||
|
|
||||||
/// Enqueues a message to one or more resolved handles.
|
|
||||||
/// Parameters:
|
|
||||||
/// - handle_ids: The resolved handle IDs for the new conversation.
|
|
||||||
/// - text: The text of the message to send.
|
|
||||||
/// - attachment_guids: The GUIDs of the attachments to send.
|
|
||||||
/// - reply: The outgoing message ID (not the server-assigned message ID).
|
|
||||||
NewConversation(Vec<String>, String, Vec<String>, Reply<Uuid>),
|
|
||||||
|
|
||||||
/// Notifies the daemon that a message has been sent.
|
/// Notifies the daemon that a message has been sent.
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use kordophone_db::{
|
|||||||
|
|
||||||
use kordophone::api::http_client::HTTPAPIClient;
|
use kordophone::api::http_client::HTTPAPIClient;
|
||||||
use kordophone::api::APIInterface;
|
use kordophone::api::APIInterface;
|
||||||
use kordophone::model::outgoing_message::{OutgoingMessage, OutgoingMessageTarget};
|
use kordophone::model::outgoing_message::OutgoingMessage;
|
||||||
use kordophone::model::{ConversationID, MessageID};
|
use kordophone::model::{ConversationID, MessageID};
|
||||||
|
|
||||||
mod update_monitor;
|
mod update_monitor;
|
||||||
@@ -330,14 +330,10 @@ impl Daemon {
|
|||||||
let _ = reply.send(());
|
let _ = reply.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::Reply(conversation_id, text, attachment_guids, reply) => {
|
Event::SendMessage(conversation_id, text, attachment_guids, reply) => {
|
||||||
let conversation_id = conversation_id.clone();
|
let conversation_id = conversation_id.clone();
|
||||||
let uuid = self
|
let uuid = self
|
||||||
.enqueue_outgoing_message(
|
.enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids)
|
||||||
text,
|
|
||||||
OutgoingMessageTarget::Conversation(conversation_id.clone()),
|
|
||||||
attachment_guids,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
let _ = reply.send(uuid);
|
let _ = reply.send(uuid);
|
||||||
|
|
||||||
@@ -348,52 +344,12 @@ impl Daemon {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::NewConversation(handle_ids, text, attachment_guids, reply) => {
|
|
||||||
let uuid = self
|
|
||||||
.enqueue_outgoing_message(
|
|
||||||
text,
|
|
||||||
OutgoingMessageTarget::Handles(handle_ids),
|
|
||||||
attachment_guids,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = reply.send(uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Event::MessageSent(message, outgoing_message, conversation_id) => {
|
Event::MessageSent(message, outgoing_message, conversation_id) => {
|
||||||
log::info!(target: target::EVENT, "Daemon: message sent: {}", message.id);
|
log::info!(target: target::EVENT, "Daemon: message sent: {}", message.id);
|
||||||
|
|
||||||
let conversation_created = match self
|
|
||||||
.ensure_conversation_exists_for_sent_message(
|
|
||||||
&conversation_id,
|
|
||||||
&outgoing_message,
|
|
||||||
&message,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(created) => created,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!(
|
|
||||||
target: target::EVENT,
|
|
||||||
"Failed to ensure conversation {} exists for sent message {}: {}",
|
|
||||||
conversation_id,
|
|
||||||
message.id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if conversation_created {
|
|
||||||
self.signal_sender
|
|
||||||
.send(Signal::ConversationsUpdated)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the message into the database.
|
// Insert the message into the database.
|
||||||
log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id);
|
log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id);
|
||||||
if let Err(e) = self
|
self.database
|
||||||
.database
|
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.with_repository(|r| {
|
.with_repository(|r| {
|
||||||
@@ -407,24 +363,13 @@ impl Daemon {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
.unwrap();
|
||||||
log::error!(
|
|
||||||
target: target::EVENT,
|
|
||||||
"Failed to persist sent message {} for conversation {}: {}",
|
|
||||||
message.id,
|
|
||||||
conversation_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from outgoing messages.
|
// Remove from outgoing messages.
|
||||||
log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid);
|
log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid);
|
||||||
for messages in self.outgoing_messages.values_mut() {
|
|
||||||
messages.retain(|m| m.guid != outgoing_message.guid);
|
|
||||||
}
|
|
||||||
self.outgoing_messages
|
self.outgoing_messages
|
||||||
.retain(|_, messages| !messages.is_empty());
|
.get_mut(&conversation_id)
|
||||||
|
.map(|messages| messages.retain(|m| m.guid != outgoing_message.guid));
|
||||||
|
|
||||||
// Send message updated signal.
|
// Send message updated signal.
|
||||||
self.signal_sender
|
self.signal_sender
|
||||||
@@ -532,8 +477,9 @@ impl Daemon {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Convert DB messages to daemon model, substituting local_id when an alias exists.
|
// Convert DB messages to daemon model, substituting local_id when an alias exists.
|
||||||
let mut result: Vec<Message> =
|
let mut result: Vec<Message> = Vec::with_capacity(
|
||||||
Vec::with_capacity(db_messages.len() + outgoing_messages.len());
|
db_messages.len() + outgoing_messages.len(),
|
||||||
|
);
|
||||||
for m in db_messages.into_iter() {
|
for m in db_messages.into_iter() {
|
||||||
let server_id = m.id.clone();
|
let server_id = m.id.clone();
|
||||||
let mut dm: Message = m.into();
|
let mut dm: Message = m.into();
|
||||||
@@ -572,87 +518,24 @@ impl Daemon {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_conversation_exists_for_sent_message(
|
|
||||||
&mut self,
|
|
||||||
conversation_id: &ConversationID,
|
|
||||||
outgoing_message: &OutgoingMessage,
|
|
||||||
message: &Message,
|
|
||||||
) -> Result<bool> {
|
|
||||||
let conversation_exists = self
|
|
||||||
.database
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.with_repository(|r| r.get_conversation_by_guid(conversation_id))
|
|
||||||
.await?
|
|
||||||
.is_some();
|
|
||||||
|
|
||||||
if conversation_exists {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let participants = Self::participants_for_outgoing_message(outgoing_message);
|
|
||||||
let mut builder = Conversation::builder()
|
|
||||||
.guid(conversation_id)
|
|
||||||
.date(message.date)
|
|
||||||
.unread_count(0)
|
|
||||||
.participants(participants);
|
|
||||||
|
|
||||||
if !message.text.trim().is_empty() {
|
|
||||||
builder = builder.last_message_preview(&message.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
let conversation = builder.build();
|
|
||||||
log::info!(
|
|
||||||
target: target::EVENT,
|
|
||||||
"Creating local conversation {} from sent message {}",
|
|
||||||
conversation_id,
|
|
||||||
message.id
|
|
||||||
);
|
|
||||||
|
|
||||||
self.database
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.with_repository(|r| r.insert_conversation(conversation))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn participants_for_outgoing_message(outgoing_message: &OutgoingMessage) -> Vec<DbParticipant> {
|
|
||||||
let handle_ids = match &outgoing_message.target {
|
|
||||||
OutgoingMessageTarget::Conversation(_) => return Vec::new(),
|
|
||||||
OutgoingMessageTarget::Handles(handle_ids) => handle_ids,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut contact_resolver = ContactResolver::new(DefaultContactResolverBackend::default());
|
|
||||||
handle_ids
|
|
||||||
.iter()
|
|
||||||
.map(|handle| DbParticipant::Remote {
|
|
||||||
handle: handle.clone(),
|
|
||||||
contact_id: contact_resolver.resolve_contact_id(handle),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn enqueue_outgoing_message(
|
async fn enqueue_outgoing_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: String,
|
text: String,
|
||||||
target: OutgoingMessageTarget,
|
conversation_id: String,
|
||||||
attachment_guids: Vec<String>,
|
attachment_guids: Vec<String>,
|
||||||
) -> Uuid {
|
) -> Uuid {
|
||||||
|
let conversation_id = conversation_id.clone();
|
||||||
let outgoing_message = OutgoingMessage::builder()
|
let outgoing_message = OutgoingMessage::builder()
|
||||||
.text(text)
|
.text(text)
|
||||||
.target(target)
|
.conversation_id(conversation_id.clone())
|
||||||
.file_transfer_guids(attachment_guids)
|
.file_transfer_guids(attachment_guids)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if let Some(conversation_id) = outgoing_message.conversation_id().cloned() {
|
// Keep a record of this so we can provide a consistent model to the client.
|
||||||
// Keep a record of replies so we can provide a consistent model to the client.
|
self.outgoing_messages
|
||||||
self.outgoing_messages
|
.entry(conversation_id)
|
||||||
.entry(conversation_id)
|
.or_insert(vec![])
|
||||||
.or_insert(vec![])
|
.push(outgoing_message.clone());
|
||||||
.push(outgoing_message.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let guid = outgoing_message.guid.clone();
|
let guid = outgoing_message.guid.clone();
|
||||||
self.post_office_sink
|
self.post_office_sink
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use tokio_condvar::Condvar;
|
|||||||
use crate::daemon::events::Event as DaemonEvent;
|
use crate::daemon::events::Event as DaemonEvent;
|
||||||
use kordophone::api::APIInterface;
|
use kordophone::api::APIInterface;
|
||||||
use kordophone::model::outgoing_message::OutgoingMessage;
|
use kordophone::model::outgoing_message::OutgoingMessage;
|
||||||
use kordophone::model::OutgoingMessageTarget;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -103,29 +102,10 @@ impl<C: APIInterface, F: AsyncFnMut() -> Result<C>> PostOffice<C, F> {
|
|||||||
Ok(sent_message) => {
|
Ok(sent_message) => {
|
||||||
log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid);
|
log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid);
|
||||||
|
|
||||||
let conversation_id = sent_message.conversation_id.clone().or_else(|| {
|
let conversation_id = message.conversation_id.clone();
|
||||||
match &message.target {
|
let event =
|
||||||
OutgoingMessageTarget::Conversation(conversation_id) => {
|
DaemonEvent::MessageSent(sent_message.into(), message, conversation_id);
|
||||||
Some(conversation_id.clone())
|
event_sink.send(event).await.unwrap();
|
||||||
}
|
|
||||||
OutgoingMessageTarget::Handles(_) => None,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(conversation_id) = conversation_id {
|
|
||||||
let event = DaemonEvent::MessageSent(
|
|
||||||
sent_message.message.into(),
|
|
||||||
message,
|
|
||||||
conversation_id,
|
|
||||||
);
|
|
||||||
event_sink.send(event).await.unwrap();
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
target: target::POST_OFFICE,
|
|
||||||
"Message sent but no conversation id was available for {}",
|
|
||||||
message.guid
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -318,49 +318,49 @@ impl DbusRepository for DBusAgent {
|
|||||||
.attachments
|
.attachments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|attachment| {
|
.map(|attachment| {
|
||||||
attachment_count += 1;
|
attachment_count += 1;
|
||||||
let mut attachment_map = arg::PropMap::new();
|
let mut attachment_map = arg::PropMap::new();
|
||||||
attachment_map.insert(
|
attachment_map.insert(
|
||||||
"guid".into(),
|
"guid".into(),
|
||||||
arg::Variant(Box::new(attachment.guid.clone())),
|
arg::Variant(Box::new(attachment.guid.clone())),
|
||||||
);
|
);
|
||||||
attachment_map.insert(
|
attachment_map.insert(
|
||||||
"downloaded".into(),
|
"downloaded".into(),
|
||||||
arg::Variant(Box::new(attachment.is_downloaded(false))),
|
arg::Variant(Box::new(attachment.is_downloaded(false))),
|
||||||
);
|
);
|
||||||
attachment_map.insert(
|
attachment_map.insert(
|
||||||
"preview_downloaded".into(),
|
"preview_downloaded".into(),
|
||||||
arg::Variant(Box::new(attachment.is_downloaded(true))),
|
arg::Variant(Box::new(attachment.is_downloaded(true))),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref metadata) = attachment.metadata {
|
if let Some(ref metadata) = attachment.metadata {
|
||||||
let mut metadata_map = arg::PropMap::new();
|
let mut metadata_map = arg::PropMap::new();
|
||||||
|
|
||||||
if let Some(ref attribution_info) = metadata.attribution_info {
|
if let Some(ref attribution_info) = metadata.attribution_info {
|
||||||
let mut attribution_map = arg::PropMap::new();
|
let mut attribution_map = arg::PropMap::new();
|
||||||
if let Some(width) = attribution_info.width {
|
if let Some(width) = attribution_info.width {
|
||||||
attribution_map.insert(
|
attribution_map.insert(
|
||||||
"width".into(),
|
"width".into(),
|
||||||
arg::Variant(Box::new(width as i32)),
|
arg::Variant(Box::new(width as i32)),
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(height) = attribution_info.height {
|
|
||||||
attribution_map.insert(
|
|
||||||
"height".into(),
|
|
||||||
arg::Variant(Box::new(height as i32)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
metadata_map.insert(
|
|
||||||
"attribution_info".into(),
|
|
||||||
arg::Variant(Box::new(attribution_map)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(height) = attribution_info.height {
|
||||||
attachment_map.insert(
|
attribution_map.insert(
|
||||||
"metadata".into(),
|
"height".into(),
|
||||||
arg::Variant(Box::new(metadata_map)),
|
arg::Variant(Box::new(height as i32)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
metadata_map.insert(
|
||||||
|
"attribution_info".into(),
|
||||||
|
arg::Variant(Box::new(attribution_map)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachment_map.insert(
|
||||||
|
"metadata".into(),
|
||||||
|
arg::Variant(Box::new(metadata_map)),
|
||||||
|
);
|
||||||
|
}
|
||||||
attachment_map
|
attachment_map
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -388,23 +388,13 @@ impl DbusRepository for DBusAgent {
|
|||||||
self.send_event_sync(Event::DeleteAllConversations)
|
self.send_event_sync(Event::DeleteAllConversations)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reply(
|
fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
text: String,
|
text: String,
|
||||||
attachment_guids: Vec<String>,
|
attachment_guids: Vec<String>,
|
||||||
) -> Result<String, MethodErr> {
|
) -> Result<String, MethodErr> {
|
||||||
self.send_event_sync(|r| Event::Reply(conversation_id, text, attachment_guids, r))
|
self.send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r))
|
||||||
.map(|uuid| uuid.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_conversation(
|
|
||||||
&mut self,
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
text: String,
|
|
||||||
attachment_guids: Vec<String>,
|
|
||||||
) -> Result<String, MethodErr> {
|
|
||||||
self.send_event_sync(|r| Event::NewConversation(handle_ids, text, attachment_guids, r))
|
|
||||||
.map(|uuid| uuid.to_string())
|
.map(|uuid| uuid.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ impl XpcAgent {
|
|||||||
|
|
||||||
// Drop any cleanup resource now that payload is constructed and sent.
|
// Drop any cleanup resource now that payload is constructed and sent.
|
||||||
drop(result.cleanup);
|
drop(result.cleanup);
|
||||||
|
|
||||||
log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method);
|
log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method);
|
||||||
} else {
|
} else {
|
||||||
log::warn!(target: LOG_TARGET, "No reply port for method: {}", method);
|
log::warn!(target: LOG_TARGET, "No reply port for method: {}", method);
|
||||||
|
|||||||
@@ -15,16 +15,10 @@ pub struct DispatchResult {
|
|||||||
|
|
||||||
impl DispatchResult {
|
impl DispatchResult {
|
||||||
pub fn new(message: Message) -> Self {
|
pub fn new(message: Message) -> Self {
|
||||||
Self {
|
Self { message, cleanup: None }
|
||||||
message,
|
|
||||||
cleanup: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_cleanup<T: Any + Send + 'static>(message: Message, cleanup: T) -> Self {
|
pub fn with_cleanup<T: Any + Send + 'static>(message: Message, cleanup: T) -> Self {
|
||||||
Self {
|
Self { message, cleanup: Some(Box::new(cleanup)) }
|
||||||
message,
|
|
||||||
cleanup: Some(Box::new(cleanup)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,12 +105,7 @@ pub async fn dispatch(
|
|||||||
.and_then(|m| dict_get_str(m, "conversation_id"))
|
.and_then(|m| dict_get_str(m, "conversation_id"))
|
||||||
{
|
{
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing conversation_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match agent
|
match agent
|
||||||
.send_event(|r| Event::SyncConversation(conversation_id, r))
|
.send_event(|r| Event::SyncConversation(conversation_id, r))
|
||||||
@@ -127,12 +122,7 @@ pub async fn dispatch(
|
|||||||
.and_then(|m| dict_get_str(m, "conversation_id"))
|
.and_then(|m| dict_get_str(m, "conversation_id"))
|
||||||
{
|
{
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing conversation_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match agent
|
match agent
|
||||||
.send_event(|r| Event::MarkConversationAsRead(conversation_id, r))
|
.send_event(|r| Event::MarkConversationAsRead(conversation_id, r))
|
||||||
@@ -147,21 +137,11 @@ pub async fn dispatch(
|
|||||||
"GetMessages" => {
|
"GetMessages" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let conversation_id = match dict_get_str(args, "conversation_id") {
|
let conversation_id = match dict_get_str(args, "conversation_id") {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing conversation_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let last_message_id = dict_get_str(args, "last_message_id");
|
let last_message_id = dict_get_str(args, "last_message_id");
|
||||||
match agent
|
match agent
|
||||||
@@ -178,10 +158,13 @@ pub async fn dispatch(
|
|||||||
dict_put_str(&mut m, "sender", &msg.sender.display_name());
|
dict_put_str(&mut m, "sender", &msg.sender.display_name());
|
||||||
|
|
||||||
// Include attachment GUIDs for the client to resolve/download
|
// Include attachment GUIDs for the client to resolve/download
|
||||||
let attachment_guids: Vec<String> =
|
let attachment_guids: Vec<String> = msg
|
||||||
msg.attachments.iter().map(|a| a.guid.clone()).collect();
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.guid.clone())
|
||||||
|
.collect();
|
||||||
m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids));
|
m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids));
|
||||||
|
|
||||||
// Full attachments array with metadata (mirrors DBus fields)
|
// Full attachments array with metadata (mirrors DBus fields)
|
||||||
let mut attachments_items: Vec<Message> = Vec::new();
|
let mut attachments_items: Vec<Message> = Vec::new();
|
||||||
for attachment in msg.attachments.iter() {
|
for attachment in msg.attachments.iter() {
|
||||||
@@ -210,23 +193,12 @@ pub async fn dispatch(
|
|||||||
if let Some(attribution_info) = &metadata.attribution_info {
|
if let Some(attribution_info) = &metadata.attribution_info {
|
||||||
let mut attribution_map: XpcMap = HashMap::new();
|
let mut attribution_map: XpcMap = HashMap::new();
|
||||||
if let Some(width) = attribution_info.width {
|
if let Some(width) = attribution_info.width {
|
||||||
dict_put_i64_as_str(
|
dict_put_i64_as_str(&mut attribution_map, "width", width as i64);
|
||||||
&mut attribution_map,
|
|
||||||
"width",
|
|
||||||
width as i64,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Some(height) = attribution_info.height {
|
if let Some(height) = attribution_info.height {
|
||||||
dict_put_i64_as_str(
|
dict_put_i64_as_str(&mut attribution_map, "height", height as i64);
|
||||||
&mut attribution_map,
|
|
||||||
"height",
|
|
||||||
height as i64,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
metadata_map.insert(
|
metadata_map.insert(cstr("attribution_info"), Message::Dictionary(attribution_map));
|
||||||
cstr("attribution_info"),
|
|
||||||
Message::Dictionary(attribution_map),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if !metadata_map.is_empty() {
|
if !metadata_map.is_empty() {
|
||||||
a.insert(cstr("metadata"), Message::Dictionary(metadata_map));
|
a.insert(cstr("metadata"), Message::Dictionary(metadata_map));
|
||||||
@@ -236,7 +208,7 @@ pub async fn dispatch(
|
|||||||
attachments_items.push(Message::Dictionary(a));
|
attachments_items.push(Message::Dictionary(a));
|
||||||
}
|
}
|
||||||
m.insert(cstr("attachments"), Message::Array(attachments_items));
|
m.insert(cstr("attachments"), Message::Array(attachments_items));
|
||||||
|
|
||||||
items.push(Message::Dictionary(m));
|
items.push(Message::Dictionary(m));
|
||||||
}
|
}
|
||||||
let mut reply: XpcMap = HashMap::new();
|
let mut reply: XpcMap = HashMap::new();
|
||||||
@@ -254,25 +226,15 @@ pub async fn dispatch(
|
|||||||
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
|
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reply
|
// SendMessage
|
||||||
"Reply" => {
|
"SendMessage" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let conversation_id = match dict_get_str(args, "conversation_id") {
|
let conversation_id = match dict_get_str(args, "conversation_id") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing conversation_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let text = dict_get_str(args, "text").unwrap_or_default();
|
let text = dict_get_str(args, "text").unwrap_or_default();
|
||||||
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
|
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
|
||||||
@@ -286,64 +248,12 @@ pub async fn dispatch(
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
match agent
|
match agent
|
||||||
.send_event(|r| Event::Reply(conversation_id, text, attachment_guids, r))
|
.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(uuid) => {
|
Ok(uuid) => {
|
||||||
let mut reply: XpcMap = HashMap::new();
|
let mut reply: XpcMap = HashMap::new();
|
||||||
dict_put_str(&mut reply, "type", "ReplyResponse");
|
dict_put_str(&mut reply, "type", "SendMessageResponse");
|
||||||
dict_put_str(&mut reply, "uuid", &uuid.to_string());
|
|
||||||
DispatchResult::new(Message::Dictionary(reply))
|
|
||||||
}
|
|
||||||
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConversation
|
|
||||||
"NewConversation" => {
|
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
|
||||||
Some(a) => a,
|
|
||||||
None => {
|
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let handle_ids: Vec<String> = match args.get(&cstr("handle_ids")) {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
if handle_ids.is_empty() {
|
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing handle_ids",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let text = dict_get_str(args, "text").unwrap_or_default();
|
|
||||||
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
match agent
|
|
||||||
.send_event(|r| Event::NewConversation(handle_ids, text, attachment_guids, r))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(uuid) => {
|
|
||||||
let mut reply: XpcMap = HashMap::new();
|
|
||||||
dict_put_str(&mut reply, "type", "NewConversationResponse");
|
|
||||||
dict_put_str(&mut reply, "uuid", &uuid.to_string());
|
dict_put_str(&mut reply, "uuid", &uuid.to_string());
|
||||||
DispatchResult::new(Message::Dictionary(reply))
|
DispatchResult::new(Message::Dictionary(reply))
|
||||||
}
|
}
|
||||||
@@ -355,21 +265,11 @@ pub async fn dispatch(
|
|||||||
"GetAttachmentInfo" => {
|
"GetAttachmentInfo" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let attachment_id = match dict_get_str(args, "attachment_id") {
|
let attachment_id = match dict_get_str(args, "attachment_id") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing attachment_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match agent
|
match agent
|
||||||
.send_event(|r| Event::GetAttachment(attachment_id, r))
|
.send_event(|r| Event::GetAttachment(attachment_id, r))
|
||||||
@@ -408,21 +308,11 @@ pub async fn dispatch(
|
|||||||
"OpenAttachmentFd" => {
|
"OpenAttachmentFd" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let attachment_id = match dict_get_str(args, "attachment_id") {
|
let attachment_id = match dict_get_str(args, "attachment_id") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing attachment_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let preview = dict_get_str(args, "preview")
|
let preview = dict_get_str(args, "preview")
|
||||||
.map(|s| s == "true")
|
.map(|s| s == "true")
|
||||||
@@ -434,7 +324,7 @@ pub async fn dispatch(
|
|||||||
{
|
{
|
||||||
Ok(attachment) => {
|
Ok(attachment) => {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
let path = attachment.get_path_for_preview(preview);
|
let path = attachment.get_path_for_preview(preview);
|
||||||
match std::fs::OpenOptions::new().read(true).open(&path) {
|
match std::fs::OpenOptions::new().read(true).open(&path) {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
@@ -445,14 +335,9 @@ pub async fn dispatch(
|
|||||||
dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse");
|
dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse");
|
||||||
reply.insert(cstr("fd"), Message::Fd(fd));
|
reply.insert(cstr("fd"), Message::Fd(fd));
|
||||||
|
|
||||||
DispatchResult {
|
DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) }
|
||||||
message: Message::Dictionary(reply),
|
|
||||||
cleanup: Some(Box::new(file)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e)))
|
|
||||||
}
|
}
|
||||||
|
Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
|
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
|
||||||
@@ -463,21 +348,11 @@ pub async fn dispatch(
|
|||||||
"DownloadAttachment" => {
|
"DownloadAttachment" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let attachment_id = match dict_get_str(args, "attachment_id") {
|
let attachment_id = match dict_get_str(args, "attachment_id") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing attachment_id",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let preview = dict_get_str(args, "preview")
|
let preview = dict_get_str(args, "preview")
|
||||||
.map(|s| s == "true")
|
.map(|s| s == "true")
|
||||||
@@ -496,18 +371,11 @@ pub async fn dispatch(
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let path = match dict_get_str(args, "path") {
|
let path = match dict_get_str(args, "path") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path")),
|
||||||
return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path"))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match agent
|
match agent
|
||||||
.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r))
|
.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r))
|
||||||
@@ -545,12 +413,7 @@ pub async fn dispatch(
|
|||||||
"UpdateSettings" => {
|
"UpdateSettings" => {
|
||||||
let args = match get_dictionary_field(root, "arguments") {
|
let args = match get_dictionary_field(root, "arguments") {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
|
||||||
return DispatchResult::new(make_error_reply(
|
|
||||||
"InvalidRequest",
|
|
||||||
"Missing arguments",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let server_url = dict_get_str(args, "server_url");
|
let server_url = dict_get_str(args, "server_url");
|
||||||
let username = dict_get_str(args, "username");
|
let username = dict_get_str(args, "username");
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ use kordophone::api::InMemoryAuthenticationStore;
|
|||||||
use kordophone::APIInterface;
|
use kordophone::APIInterface;
|
||||||
|
|
||||||
use crate::printers::{ConversationPrinter, MessagePrinter};
|
use crate::printers::{ConversationPrinter, MessagePrinter};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::Result;
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use kordophone::model::event::EventData;
|
use kordophone::model::event::EventData;
|
||||||
use kordophone::model::{HandleResolutionStatus, OutgoingMessage, OutgoingMessageTarget};
|
use kordophone::model::outgoing_message::OutgoingMessage;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
@@ -47,29 +47,14 @@ pub enum Commands {
|
|||||||
/// Prints all raw updates from the server.
|
/// Prints all raw updates from the server.
|
||||||
RawUpdates,
|
RawUpdates,
|
||||||
|
|
||||||
/// Resolves an address to a canonical handle.
|
/// Sends a message to the server.
|
||||||
#[command(alias = "resolve")]
|
SendMessage {
|
||||||
ResolveHandle { address: String },
|
|
||||||
|
|
||||||
/// Replies to an existing conversation.
|
|
||||||
#[command(alias = "send-message")]
|
|
||||||
Reply {
|
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Starts a new message to one or more resolved handles.
|
|
||||||
New {
|
|
||||||
#[arg(long = "handle", required = true)]
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Marks a conversation as read.
|
/// Marks a conversation as read.
|
||||||
Mark { conversation_id: String },
|
Mark { conversation_id: String },
|
||||||
|
|
||||||
/// Deletes a conversation from the server.
|
|
||||||
Delete { conversation_id: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Commands {
|
impl Commands {
|
||||||
@@ -81,21 +66,13 @@ impl Commands {
|
|||||||
Commands::Messages { conversation_id } => client.print_messages(conversation_id).await,
|
Commands::Messages { conversation_id } => client.print_messages(conversation_id).await,
|
||||||
Commands::RawUpdates => client.print_raw_updates().await,
|
Commands::RawUpdates => client.print_raw_updates().await,
|
||||||
Commands::Events => client.print_events().await,
|
Commands::Events => client.print_events().await,
|
||||||
Commands::ResolveHandle { address } => client.resolve_handle(address).await,
|
Commands::SendMessage {
|
||||||
Commands::Reply {
|
|
||||||
conversation_id,
|
conversation_id,
|
||||||
message,
|
message,
|
||||||
} => client.reply(conversation_id, message).await,
|
} => client.send_message(conversation_id, message).await,
|
||||||
Commands::New {
|
|
||||||
handle_ids,
|
|
||||||
message,
|
|
||||||
} => client.new_message(handle_ids, message).await,
|
|
||||||
Commands::Mark { conversation_id } => {
|
Commands::Mark { conversation_id } => {
|
||||||
client.mark_conversation_as_read(conversation_id).await
|
client.mark_conversation_as_read(conversation_id).await
|
||||||
}
|
}
|
||||||
Commands::Delete { conversation_id } => {
|
|
||||||
client.delete_conversation(conversation_id).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,14 +146,16 @@ impl ClientCli {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match stream.next().await.unwrap() {
|
match stream.next().await.unwrap() {
|
||||||
Ok(update) => match update {
|
Ok(update) => {
|
||||||
SocketUpdate::Update(updates) => {
|
match update {
|
||||||
for update in updates {
|
SocketUpdate::Update(updates) => {
|
||||||
println!("Got update: {:?}", update);
|
for update in updates {
|
||||||
|
println!("Got update: {:?}", update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SocketUpdate::Pong => {
|
||||||
|
println!("Pong");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
SocketUpdate::Pong => {
|
|
||||||
println!("Pong");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -190,91 +169,20 @@ impl ClientCli {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resolve_handle(&mut self, address: String) -> Result<()> {
|
pub async fn send_message(&mut self, conversation_id: String, message: String) -> Result<()> {
|
||||||
let response = self.api.resolve_handle(&address).await?;
|
|
||||||
let status = match response.status {
|
|
||||||
HandleResolutionStatus::Valid => "valid",
|
|
||||||
HandleResolutionStatus::Invalid => "invalid",
|
|
||||||
HandleResolutionStatus::Unknown => "unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Resolved handle: {}", response.resolved_handle.id);
|
|
||||||
|
|
||||||
if let Some(name) = response.resolved_handle.name {
|
|
||||||
println!("Name: {}", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Status: {}", status);
|
|
||||||
|
|
||||||
if let Some(conversation_id) = response.existing_chat {
|
|
||||||
println!("Existing conversation: {}", conversation_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_message(&mut self, target: OutgoingMessageTarget, message: String) -> Result<()> {
|
|
||||||
let outgoing_message = OutgoingMessage::builder()
|
let outgoing_message = OutgoingMessage::builder()
|
||||||
.target(target)
|
.conversation_id(conversation_id)
|
||||||
.text(message)
|
.text(message)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let response = self.api.send_message(&outgoing_message).await?;
|
let message = self.api.send_message(&outgoing_message).await?;
|
||||||
if let Some(conversation_id) = response.conversation_id {
|
println!("Message sent: {}", message.guid);
|
||||||
println!(
|
|
||||||
"Message sent: {} conversation: {}",
|
|
||||||
response.message.guid, conversation_id
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!("Message sent: {}", response.message.guid);
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_handle_ids(&mut self, handle_ids: Vec<String>) -> Result<Vec<String>> {
|
|
||||||
let mut resolved_handle_ids = Vec::with_capacity(handle_ids.len());
|
|
||||||
|
|
||||||
for handle_id in handle_ids {
|
|
||||||
let response = self.api.resolve_handle(&handle_id).await?;
|
|
||||||
match response.status {
|
|
||||||
HandleResolutionStatus::Valid => {
|
|
||||||
resolved_handle_ids.push(response.resolved_handle.id);
|
|
||||||
}
|
|
||||||
HandleResolutionStatus::Invalid => {
|
|
||||||
bail!("Handle '{}' is not iMessage-capable.", handle_id);
|
|
||||||
}
|
|
||||||
HandleResolutionStatus::Unknown => {
|
|
||||||
bail!("Handle '{}' could not be resolved.", handle_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(resolved_handle_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reply(&mut self, conversation_id: String, message: String) -> Result<()> {
|
|
||||||
self.send_message(
|
|
||||||
OutgoingMessageTarget::Conversation(conversation_id),
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn new_message(&mut self, handle_ids: Vec<String>, message: String) -> Result<()> {
|
|
||||||
let resolved_handle_ids = self.resolve_handle_ids(handle_ids).await?;
|
|
||||||
self.send_message(OutgoingMessageTarget::Handles(resolved_handle_ids), message)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
||||||
self.api.mark_conversation_as_read(&conversation_id).await?;
|
self.api.mark_conversation_as_read(&conversation_id).await?;
|
||||||
println!("Conversation marked as read: {}", conversation_id);
|
println!("Conversation marked as read: {}", conversation_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_conversation(&mut self, conversation_id: String) -> Result<()> {
|
|
||||||
self.api.delete_conversation(&conversation_id).await?;
|
|
||||||
println!("Conversation deleted: {}", conversation_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,20 +109,15 @@ impl DaemonInterface for DBusDaemonInterface {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reply(&mut self, conversation_id: String, text: String) -> Result<()> {
|
async fn enqueue_outgoing_message(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
) -> Result<()> {
|
||||||
let attachment_guids: Vec<&str> = vec![];
|
let attachment_guids: Vec<&str> = vec![];
|
||||||
let outgoing_message_id =
|
let outgoing_message_id = KordophoneRepository::send_message(
|
||||||
KordophoneRepository::reply(&self.proxy(), &conversation_id, &text, attachment_guids)?;
|
|
||||||
println!("Outgoing message ID: {}", outgoing_message_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_conversation(&mut self, handle_ids: Vec<String>, text: String) -> Result<()> {
|
|
||||||
let attachment_guids: Vec<&str> = vec![];
|
|
||||||
let handle_ids: Vec<&str> = handle_ids.iter().map(String::as_str).collect();
|
|
||||||
let outgoing_message_id = KordophoneRepository::new_conversation(
|
|
||||||
&self.proxy(),
|
&self.proxy(),
|
||||||
handle_ids,
|
&conversation_id,
|
||||||
&text,
|
&text,
|
||||||
attachment_guids,
|
attachment_guids,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ pub trait DaemonInterface {
|
|||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
last_message_id: Option<String>,
|
last_message_id: Option<String>,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
async fn reply(&mut self, conversation_id: String, text: String) -> Result<()>;
|
async fn enqueue_outgoing_message(
|
||||||
async fn new_conversation(&mut self, handle_ids: Vec<String>, text: String) -> Result<()>;
|
&mut self,
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
) -> Result<()>;
|
||||||
async fn wait_for_signals(&mut self) -> Result<()>;
|
async fn wait_for_signals(&mut self) -> Result<()>;
|
||||||
async fn config(&mut self, cmd: ConfigCommands) -> Result<()>;
|
async fn config(&mut self, cmd: ConfigCommands) -> Result<()>;
|
||||||
async fn delete_all_conversations(&mut self) -> Result<()>;
|
async fn delete_all_conversations(&mut self) -> Result<()>;
|
||||||
@@ -70,12 +73,11 @@ impl DaemonInterface for StubDaemonInterface {
|
|||||||
"Daemon interface not implemented on this platform"
|
"Daemon interface not implemented on this platform"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
async fn reply(&mut self, _conversation_id: String, _text: String) -> Result<()> {
|
async fn enqueue_outgoing_message(
|
||||||
Err(anyhow::anyhow!(
|
&mut self,
|
||||||
"Daemon interface not implemented on this platform"
|
_conversation_id: String,
|
||||||
))
|
_text: String,
|
||||||
}
|
) -> Result<()> {
|
||||||
async fn new_conversation(&mut self, _handle_ids: Vec<String>, _text: String) -> Result<()> {
|
|
||||||
Err(anyhow::anyhow!(
|
Err(anyhow::anyhow!(
|
||||||
"Daemon interface not implemented on this platform"
|
"Daemon interface not implemented on this platform"
|
||||||
))
|
))
|
||||||
@@ -159,20 +161,12 @@ pub enum Commands {
|
|||||||
/// Deletes all conversations.
|
/// Deletes all conversations.
|
||||||
DeleteAllConversations,
|
DeleteAllConversations,
|
||||||
|
|
||||||
/// Replies to an existing conversation.
|
/// Enqueues an outgoing message to be sent to a conversation.
|
||||||
#[command(alias = "send-message")]
|
SendMessage {
|
||||||
Reply {
|
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Starts a new conversation with one or more resolved handles.
|
|
||||||
New {
|
|
||||||
#[arg(long = "handle", required = true)]
|
|
||||||
handle_ids: Vec<String>,
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Downloads an attachment from the server to the attachment store. Returns the path to the attachment.
|
/// Downloads an attachment from the server to the attachment store. Returns the path to the attachment.
|
||||||
DownloadAttachment { attachment_id: String },
|
DownloadAttachment { attachment_id: String },
|
||||||
|
|
||||||
@@ -214,11 +208,10 @@ impl Commands {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
Commands::DeleteAllConversations => client.delete_all_conversations().await,
|
Commands::DeleteAllConversations => client.delete_all_conversations().await,
|
||||||
Commands::Reply {
|
Commands::SendMessage {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
text,
|
text,
|
||||||
} => client.reply(conversation_id, text).await,
|
} => client.enqueue_outgoing_message(conversation_id, text).await,
|
||||||
Commands::New { handle_ids, text } => client.new_conversation(handle_ids, text).await,
|
|
||||||
Commands::UploadAttachment { path } => client.upload_attachment(path).await,
|
Commands::UploadAttachment { path } => client.upload_attachment(path).await,
|
||||||
Commands::DownloadAttachment { attachment_id } => {
|
Commands::DownloadAttachment { attachment_id } => {
|
||||||
client.download_attachment(attachment_id).await
|
client.download_attachment(attachment_id).await
|
||||||
|
|||||||
@@ -371,7 +371,11 @@ impl DaemonInterface for XpcDaemonInterface {
|
|||||||
_ => Err(anyhow::anyhow!("Unexpected messages payload")),
|
_ => Err(anyhow::anyhow!("Unexpected messages payload")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn reply(&mut self, _conversation_id: String, _text: String) -> Result<()> {
|
async fn enqueue_outgoing_message(
|
||||||
|
&mut self,
|
||||||
|
_conversation_id: String,
|
||||||
|
_text: String,
|
||||||
|
) -> Result<()> {
|
||||||
let mach_port_name = Self::build_service_name()?;
|
let mach_port_name = Self::build_service_name()?;
|
||||||
let mut client = XPCClient::connect(&mach_port_name);
|
let mut client = XPCClient::connect(&mach_port_name);
|
||||||
let mut args = HashMap::new();
|
let mut args = HashMap::new();
|
||||||
@@ -383,34 +387,10 @@ impl DaemonInterface for XpcDaemonInterface {
|
|||||||
Self::key("text"),
|
Self::key("text"),
|
||||||
Message::String(CString::new(_text).unwrap()),
|
Message::String(CString::new(_text).unwrap()),
|
||||||
);
|
);
|
||||||
let response = self.call_method(&mut client, "Reply", Some(args)).await?;
|
let reply = self
|
||||||
if let Some(uuid) = Self::get_string(&response, "uuid") {
|
.call_method(&mut client, "SendMessage", Some(args))
|
||||||
println!("Outgoing message ID: {}", uuid.to_string_lossy());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_conversation(&mut self, handle_ids: Vec<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("handle_ids"),
|
|
||||||
Message::Array(
|
|
||||||
handle_ids
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle_id| Message::String(CString::new(handle_id).unwrap()))
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
args.insert(
|
|
||||||
Self::key("text"),
|
|
||||||
Message::String(CString::new(text).unwrap()),
|
|
||||||
);
|
|
||||||
let response = self
|
|
||||||
.call_method(&mut client, "NewConversation", Some(args))
|
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(uuid) = Self::get_string(&response, "uuid") {
|
if let Some(uuid) = Self::get_string(&reply, "uuid") {
|
||||||
println!("Outgoing message ID: {}", uuid.to_string_lossy());
|
println!("Outgoing message ID: {}", uuid.to_string_lossy());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -378,22 +378,20 @@ fn run_app(
|
|||||||
app.pinned_to_bottom = was_pinned;
|
app.pinned_to_bottom = was_pinned;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
daemon::Event::MessageQueued {
|
daemon::Event::MessageSent {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
outgoing_id,
|
outgoing_id,
|
||||||
} => {
|
} => {
|
||||||
if let Some(conversation_id) = conversation_id {
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
app.status = outgoing_id
|
||||||
app.status = outgoing_id
|
.as_deref()
|
||||||
.as_deref()
|
.map(|id| format!("Sent ({id})"))
|
||||||
.map(|id| format!("Sent ({id})"))
|
.unwrap_or_else(|| "Sent".to_string());
|
||||||
.unwrap_or_else(|| "Sent".to_string());
|
app.refresh_messages_in_flight = false;
|
||||||
app.refresh_messages_in_flight = false;
|
request_tx
|
||||||
request_tx
|
.send(daemon::Request::RefreshMessages { conversation_id })
|
||||||
.send(daemon::Request::RefreshMessages { conversation_id })
|
.ok();
|
||||||
.ok();
|
app.refresh_messages_in_flight = true;
|
||||||
app.refresh_messages_in_flight = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
daemon::Event::MarkedRead => {}
|
daemon::Event::MarkedRead => {}
|
||||||
@@ -640,7 +638,7 @@ fn handle_chat_keys(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
request_tx
|
request_tx
|
||||||
.send(daemon::Request::Reply {
|
.send(daemon::Request::SendMessage {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use kordophone::api::AuthenticationStore;
|
|
||||||
use kordophone::api::http_client::Credentials;
|
|
||||||
use kordophone::{
|
use kordophone::{
|
||||||
APIInterface,
|
api::{HTTPAPIClient, InMemoryAuthenticationStore, EventSocket},
|
||||||
api::{EventSocket, HTTPAPIClient, InMemoryAuthenticationStore},
|
|
||||||
model::{ConversationID, event::EventData},
|
model::{ConversationID, event::EventData},
|
||||||
|
APIInterface,
|
||||||
};
|
};
|
||||||
|
use kordophone::api::http_client::Credentials;
|
||||||
|
use kordophone::api::AuthenticationStore;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use hyper::Uri;
|
use hyper::Uri;
|
||||||
@@ -18,10 +18,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
if args.len() < 2 {
|
if args.len() < 2 {
|
||||||
eprintln!(
|
eprintln!("Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...", args[0]);
|
||||||
"Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...",
|
|
||||||
args[0]
|
|
||||||
);
|
|
||||||
eprintln!("Environment variables required:");
|
eprintln!("Environment variables required:");
|
||||||
eprintln!(" KORDOPHONE_API_URL - Server URL");
|
eprintln!(" KORDOPHONE_API_URL - Server URL");
|
||||||
eprintln!(" KORDOPHONE_USERNAME - Username for authentication");
|
eprintln!(" KORDOPHONE_USERNAME - Username for authentication");
|
||||||
@@ -33,74 +30,65 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let server_url: Uri = env::var("KORDOPHONE_API_URL")
|
let server_url: Uri = env::var("KORDOPHONE_API_URL")
|
||||||
.map_err(|_| "KORDOPHONE_API_URL environment variable not set")?
|
.map_err(|_| "KORDOPHONE_API_URL environment variable not set")?
|
||||||
.parse()?;
|
.parse()?;
|
||||||
|
|
||||||
let username = env::var("KORDOPHONE_USERNAME")
|
let username = env::var("KORDOPHONE_USERNAME")
|
||||||
.map_err(|_| "KORDOPHONE_USERNAME environment variable not set")?;
|
.map_err(|_| "KORDOPHONE_USERNAME environment variable not set")?;
|
||||||
|
|
||||||
let password = env::var("KORDOPHONE_PASSWORD")
|
let password = env::var("KORDOPHONE_PASSWORD")
|
||||||
.map_err(|_| "KORDOPHONE_PASSWORD environment variable not set")?;
|
.map_err(|_| "KORDOPHONE_PASSWORD environment variable not set")?;
|
||||||
|
|
||||||
let credentials = Credentials { username, password };
|
let credentials = Credentials { username, password };
|
||||||
|
|
||||||
// Collect all conversation IDs from command line arguments
|
// Collect all conversation IDs from command line arguments
|
||||||
let target_conversation_ids: Vec<ConversationID> =
|
let target_conversation_ids: Vec<ConversationID> = args[1..].iter()
|
||||||
args[1..].iter().map(|id| id.clone()).collect();
|
.map(|id| id.clone())
|
||||||
|
.collect();
|
||||||
println!(
|
|
||||||
"Monitoring {} conversation(s) for updates: {:?}",
|
println!("Monitoring {} conversation(s) for updates: {:?}",
|
||||||
target_conversation_ids.len(),
|
target_conversation_ids.len(), target_conversation_ids);
|
||||||
target_conversation_ids
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone()));
|
let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone()));
|
||||||
let mut client = HTTPAPIClient::new(server_url, auth_store);
|
let mut client = HTTPAPIClient::new(server_url, auth_store);
|
||||||
let _ = client.authenticate(credentials).await?;
|
let _ = client.authenticate(credentials).await?;
|
||||||
|
|
||||||
// Open event socket
|
// Open event socket
|
||||||
let event_socket = client.open_event_socket(None).await?;
|
let event_socket = client.open_event_socket(None).await?;
|
||||||
let (mut stream, _sink) = event_socket.events().await;
|
let (mut stream, _sink) = event_socket.events().await;
|
||||||
|
|
||||||
println!("Connected to event stream, waiting for updates...");
|
println!("Connected to event stream, waiting for updates...");
|
||||||
|
|
||||||
// Process events
|
// Process events
|
||||||
while let Some(event_result) = stream.next().await {
|
while let Some(event_result) = stream.next().await {
|
||||||
match event_result {
|
match event_result {
|
||||||
Ok(socket_event) => {
|
Ok(socket_event) => {
|
||||||
match socket_event {
|
match socket_event {
|
||||||
kordophone::api::event_socket::SocketEvent::Update(event) => match event.data {
|
kordophone::api::event_socket::SocketEvent::Update(event) => {
|
||||||
EventData::MessageReceived(conversation, _message) => {
|
match event.data {
|
||||||
if target_conversation_ids.contains(&conversation.guid) {
|
EventData::MessageReceived(conversation, _message) => {
|
||||||
println!(
|
if target_conversation_ids.contains(&conversation.guid) {
|
||||||
"Message update detected for conversation {}, marking as read...",
|
println!("Message update detected for conversation {}, marking as read...", conversation.guid);
|
||||||
conversation.guid
|
match client.mark_conversation_as_read(&conversation.guid).await {
|
||||||
);
|
Ok(_) => println!("Successfully marked conversation {} as read", conversation.guid),
|
||||||
match client.mark_conversation_as_read(&conversation.guid).await {
|
Err(e) => eprintln!("Failed to mark conversation {} as read: {:?}", conversation.guid, e),
|
||||||
Ok(_) => println!(
|
}
|
||||||
"Successfully marked conversation {} as read",
|
|
||||||
conversation.guid
|
|
||||||
),
|
|
||||||
Err(e) => eprintln!(
|
|
||||||
"Failed to mark conversation {} as read: {:?}",
|
|
||||||
conversation.guid, e
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
kordophone::api::event_socket::SocketEvent::Pong => {
|
kordophone::api::event_socket::SocketEvent::Pong => {
|
||||||
// Ignore pong messages
|
// Ignore pong messages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error receiving event: {:?}", e);
|
eprintln!("Error receiving event: {:?}", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Event stream ended");
|
println!("Event stream ended");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
# GLib Bindings Plan
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Proposed. Not started.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Today the GTK app talks to `kordophoned` directly over D-Bus in
|
|
||||||
[`gtk/src/service/repository.vala`](/home/buzzert/src/Kordophone/gtk/src/service/repository.vala)
|
|
||||||
and the generated interface in
|
|
||||||
[`gtk/src/service/interface/dbusservice.vala`](/home/buzzert/src/Kordophone/gtk/src/service/interface/dbusservice.vala).
|
|
||||||
|
|
||||||
At the same time, the Rust-side daemon client logic already exists in
|
|
||||||
[`core/kordophoned-client/src/worker.rs`](/home/buzzert/src/Kordophone/core/kordophoned-client/src/worker.rs)
|
|
||||||
with platform backends for D-Bus and XPC. That means protocol changes currently
|
|
||||||
have to be reflected in multiple places:
|
|
||||||
|
|
||||||
- `kordophoned` D-Bus/XPC server shims
|
|
||||||
- `kordophoned-client` Rust transport layer
|
|
||||||
- GTK/Vala D-Bus interface and proxy code
|
|
||||||
- Swift XPC client code
|
|
||||||
|
|
||||||
For GTK/Vala specifically, the goal is to stop binding the application directly
|
|
||||||
to the daemon protocol surface.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Add a GTK-facing GLib/GObject wrapper on top of a small C ABI exported from the
|
|
||||||
Rust daemon client stack.
|
|
||||||
|
|
||||||
Do not expose the current `kordophoned-client` Rust API directly as raw C.
|
|
||||||
The current surface uses Rust enums, `Vec<String>`, `Option`, and a threaded
|
|
||||||
worker model, which is fine internally but not a good stable FFI boundary.
|
|
||||||
|
|
||||||
The recommended layering is:
|
|
||||||
|
|
||||||
1. Keep `core/kordophoned-client` as the Rust-native transport/domain layer.
|
|
||||||
2. Add a new FFI crate with a narrow, C-safe API.
|
|
||||||
3. Add a small GLib/GObject wrapper for GTK/Vala consumption.
|
|
||||||
4. Migrate the GTK app to that wrapper and remove its direct D-Bus binding code.
|
|
||||||
|
|
||||||
This keeps one transport implementation in Rust while giving Vala a natural
|
|
||||||
GObject-style API with methods, async operations, and signals.
|
|
||||||
|
|
||||||
## Why Not Direct Rust GObject Export?
|
|
||||||
|
|
||||||
Exporting a GObject API directly from Rust is possible in principle, but the
|
|
||||||
tooling for generating the introspection artifacts that Vala wants is still much
|
|
||||||
less straightforward than plain C/GObject.
|
|
||||||
|
|
||||||
For this repo, the lower-risk path is:
|
|
||||||
|
|
||||||
- Rust for the daemon client implementation
|
|
||||||
- C ABI as the stable binary boundary
|
|
||||||
- a thin C/GObject wrapper for GI/Vala
|
|
||||||
|
|
||||||
That gives us standard GLib ownership rules, normal `.gir` / `.typelib` /
|
|
||||||
`.vapi` generation, and a cleaner Meson integration story for the GTK app.
|
|
||||||
|
|
||||||
## Proposed Layout
|
|
||||||
|
|
||||||
Add a new crate:
|
|
||||||
|
|
||||||
- `core/kordophoned-client-c`
|
|
||||||
|
|
||||||
This crate should export a small `extern "C"` interface around the existing
|
|
||||||
daemon client logic.
|
|
||||||
|
|
||||||
Add a new Linux-focused wrapper library:
|
|
||||||
|
|
||||||
- `gtk/libkordophone-client-glib` or `gtk/src/service/glib/`
|
|
||||||
|
|
||||||
This wrapper should be written in C and expose a GObject API that Vala can use.
|
|
||||||
It should depend on the Rust C ABI library, not on D-Bus directly.
|
|
||||||
|
|
||||||
## Proposed Responsibilities
|
|
||||||
|
|
||||||
### `core/kordophoned-client`
|
|
||||||
|
|
||||||
- Own request/response/signal semantics.
|
|
||||||
- Own platform transport handling:
|
|
||||||
- D-Bus on Linux
|
|
||||||
- XPC on macOS
|
|
||||||
- Stay Rust-native.
|
|
||||||
|
|
||||||
### `core/kordophoned-client-c`
|
|
||||||
|
|
||||||
- Define opaque client handles.
|
|
||||||
- Define FFI-safe request/response structs.
|
|
||||||
- Define callback registration for async completions and daemon signals.
|
|
||||||
- Marshal Rust events onto C callbacks.
|
|
||||||
- Hide Rust enums and collections from C consumers.
|
|
||||||
|
|
||||||
### GLib Wrapper
|
|
||||||
|
|
||||||
- Expose a `KpDaemonClient` GObject.
|
|
||||||
- Convert C callbacks into `GTask` completions and GObject signals.
|
|
||||||
- Marshal all callbacks onto the GLib main context.
|
|
||||||
- Expose Vala-friendly model objects or boxed structs.
|
|
||||||
|
|
||||||
## Draft Public Surface
|
|
||||||
|
|
||||||
The GTK-facing API should look like a normal GLib client, not like a transport
|
|
||||||
binding.
|
|
||||||
|
|
||||||
Suggested primary type:
|
|
||||||
|
|
||||||
- `KpDaemonClient`
|
|
||||||
|
|
||||||
Suggested async methods:
|
|
||||||
|
|
||||||
- `get_conversations_async(limit, offset, cancellable, callback)`
|
|
||||||
- `get_messages_async(conversation_id, last_message_id, cancellable, callback)`
|
|
||||||
- `reply_async(conversation_id, text, attachment_guids, cancellable, callback)`
|
|
||||||
- `new_conversation_async(handle_ids, text, attachment_guids, cancellable, callback)`
|
|
||||||
- `mark_conversation_as_read_async(conversation_id, cancellable, callback)`
|
|
||||||
- `sync_conversation_async(conversation_id, cancellable, callback)`
|
|
||||||
- `sync_conversation_list_async(cancellable, callback)`
|
|
||||||
- `upload_attachment_async(path, cancellable, callback)`
|
|
||||||
- `download_attachment_async(attachment_id, preview, cancellable, callback)`
|
|
||||||
- `get_attachment_info_async(attachment_id, cancellable, callback)`
|
|
||||||
|
|
||||||
Suggested synchronous or utility methods:
|
|
||||||
|
|
||||||
- `open_attachment_fd(attachment_id, preview, error)`
|
|
||||||
- `start()`
|
|
||||||
- `stop()`
|
|
||||||
|
|
||||||
Suggested signals:
|
|
||||||
|
|
||||||
- `conversations-updated`
|
|
||||||
- `messages-updated(conversation-id)`
|
|
||||||
- `attachment-downloaded(attachment-id)`
|
|
||||||
- `attachment-uploaded(upload-guid, attachment-guid)`
|
|
||||||
- `reconnected`
|
|
||||||
- `error(message)`
|
|
||||||
|
|
||||||
The first pass does not need to expose every daemon event. It only needs enough
|
|
||||||
surface to replace the current GTK repository layer.
|
|
||||||
|
|
||||||
## Suggested Model Types
|
|
||||||
|
|
||||||
Avoid returning raw hash tables to Vala.
|
|
||||||
|
|
||||||
Add small typed model objects or boxed structs for:
|
|
||||||
|
|
||||||
- `KpConversationSummary`
|
|
||||||
- `KpChatMessage`
|
|
||||||
- `KpAttachmentInfo`
|
|
||||||
|
|
||||||
If send acknowledgements matter to the UI, add:
|
|
||||||
|
|
||||||
- `KpQueuedMessage`
|
|
||||||
|
|
||||||
The GTK app can keep its own higher-level `Repository` wrapper initially, but it
|
|
||||||
should be wrapping typed client results instead of raw D-Bus maps.
|
|
||||||
|
|
||||||
## Signal Handling
|
|
||||||
|
|
||||||
Signals are the main reason this should be a GLib wrapper instead of plain C
|
|
||||||
calls from Vala.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- daemon signal subscriptions must stay alive for the lifetime of the client
|
|
||||||
- transport callbacks must never call into GTK from a non-main thread
|
|
||||||
- all emitted GObject signals must be marshalled onto the GLib main context
|
|
||||||
|
|
||||||
The C ABI should therefore support registration of signal callbacks plus a user
|
|
||||||
data pointer, while the GLib wrapper owns the main-context handoff.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
### Phase 1: Stabilize Rust FFI Boundary
|
|
||||||
|
|
||||||
- Add FFI-safe request/response types instead of exposing the current worker
|
|
||||||
enums directly.
|
|
||||||
- Keep the Rust worker and transport code internal.
|
|
||||||
- Decide which operations are callback-based and which can be blocking.
|
|
||||||
|
|
||||||
### Phase 2: Add `kordophoned-client-c`
|
|
||||||
|
|
||||||
- Expose opaque client construction/destruction.
|
|
||||||
- Expose request entry points for the operations GTK already uses.
|
|
||||||
- Expose signal subscription hooks.
|
|
||||||
- Add explicit allocation/free helpers for returned strings and arrays.
|
|
||||||
|
|
||||||
### Phase 3: Add GLib Wrapper
|
|
||||||
|
|
||||||
- Implement `KpDaemonClient` as a GObject in C.
|
|
||||||
- Convert C callbacks into `GTask`-based async completion methods.
|
|
||||||
- Emit GObject signals for daemon events.
|
|
||||||
- Generate introspection artifacts for Vala.
|
|
||||||
|
|
||||||
### Phase 4: Migrate GTK
|
|
||||||
|
|
||||||
- Replace direct use of `DBusService.Repository` in
|
|
||||||
[`gtk/src/service/repository.vala`](/home/buzzert/src/Kordophone/gtk/src/service/repository.vala).
|
|
||||||
- Remove the generated D-Bus binding dependency from the GTK app.
|
|
||||||
- Keep the existing GTK-side repository shape initially to minimize churn.
|
|
||||||
|
|
||||||
### Phase 5: Revisit Swift
|
|
||||||
|
|
||||||
Optional.
|
|
||||||
|
|
||||||
If this turns out cleaner than the current Swift XPC wrapper, add a Swift-facing
|
|
||||||
wrapper around the same C ABI later. This is not required for the GTK migration.
|
|
||||||
|
|
||||||
## Build System Notes
|
|
||||||
|
|
||||||
This plan introduces a Cargo + Meson integration boundary.
|
|
||||||
|
|
||||||
Expected follow-up work:
|
|
||||||
|
|
||||||
- decide whether the Rust C ABI library is built via `cargo build`, `cargo-c`,
|
|
||||||
or a Meson custom target
|
|
||||||
- decide where generated headers live
|
|
||||||
- decide where `.gir`, `.typelib`, and `.vapi` artifacts are produced and
|
|
||||||
installed
|
|
||||||
|
|
||||||
The cleanest packaging story is likely:
|
|
||||||
|
|
||||||
- Cargo builds the Rust library
|
|
||||||
- Meson builds the GLib wrapper and generates introspection data
|
|
||||||
- GTK links to the GLib wrapper
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- replacing D-Bus and XPC with a custom socket transport
|
|
||||||
- unifying the macOS app onto GLib
|
|
||||||
- exposing the entire daemon protocol on day one
|
|
||||||
- redesigning GTK application architecture beyond the service boundary
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- FFI ownership mistakes across Rust, C, and GLib
|
|
||||||
- callback threading bugs if signal delivery is not marshalled correctly
|
|
||||||
- build complexity from mixed Cargo and Meson workflows
|
|
||||||
- over-exposing the current daemon protocol instead of defining a cleaner client
|
|
||||||
API
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should the C ABI be Linux-only at first, or cross-platform from day one?
|
|
||||||
- Should the first GTK-facing layer expose send acknowledgements, or just fire
|
|
||||||
and rely on message update signals?
|
|
||||||
- Should handle resolution be part of the GLib client API immediately, or added
|
|
||||||
only when GTK gains compose-new-conversation UI?
|
|
||||||
- Is it worth creating a higher-level shared protocol schema before building the
|
|
||||||
C ABI, or should that wait until after the GTK migration?
|
|
||||||
|
|
||||||
## Short Version
|
|
||||||
|
|
||||||
If we do this later, the best path is probably:
|
|
||||||
|
|
||||||
- Rust daemon client stays as the implementation core
|
|
||||||
- add a small C ABI on top of it
|
|
||||||
- add a tiny C/GObject wrapper for Vala
|
|
||||||
- move GTK off direct D-Bus bindings
|
|
||||||
|
|
||||||
That removes one of the protocol surfaces we currently maintain without forcing
|
|
||||||
the GTK app to consume a Rust-native API directly.
|
|
||||||
@@ -50,11 +50,8 @@ namespace DBusService {
|
|||||||
[DBus (name = "GetMessages")]
|
[DBus (name = "GetMessages")]
|
||||||
public abstract GLib.HashTable<string, GLib.Variant>[] get_messages(string conversation_id, string last_message_id) throws DBusError, IOError;
|
public abstract GLib.HashTable<string, GLib.Variant>[] get_messages(string conversation_id, string last_message_id) throws DBusError, IOError;
|
||||||
|
|
||||||
[DBus (name = "Reply")]
|
[DBus (name = "SendMessage")]
|
||||||
public abstract string reply(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError;
|
public abstract string send_message(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError;
|
||||||
|
|
||||||
[DBus (name = "NewConversation")]
|
|
||||||
public abstract string new_conversation(string[] handle_ids, string text, string[] attachment_guids) throws DBusError, IOError;
|
|
||||||
|
|
||||||
[DBus (name = "MessagesUpdated")]
|
[DBus (name = "MessagesUpdated")]
|
||||||
public signal void messages_updated(string conversation_id);
|
public signal void messages_updated(string conversation_id);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
</arg>
|
</arg>
|
||||||
</method>
|
</method>
|
||||||
|
|
||||||
<method name="Reply">
|
<method name="SendMessage">
|
||||||
<arg type="s" name="conversation_id" direction="in"/>
|
<arg type="s" name="conversation_id" direction="in"/>
|
||||||
<arg type="s" name="text" direction="in"/>
|
<arg type="s" name="text" direction="in"/>
|
||||||
<arg type="as" name="attachment_guids" direction="in"/>
|
<arg type="as" name="attachment_guids" direction="in"/>
|
||||||
@@ -91,28 +91,9 @@
|
|||||||
<arg type="s" name="outgoing_message_id" direction="out"/>
|
<arg type="s" name="outgoing_message_id" direction="out"/>
|
||||||
|
|
||||||
<annotation name="org.freedesktop.DBus.DocString"
|
<annotation name="org.freedesktop.DBus.DocString"
|
||||||
value="Replies to an existing conversation. Returns the outgoing message ID.
|
value="Sends a message to the server. Returns the outgoing message ID.
|
||||||
Arguments:
|
Arguments:
|
||||||
- conversation_id: The ID of the conversation to reply to.
|
- conversation_id: The ID of the conversation to send the message to.
|
||||||
- text: The text of the message to send.
|
|
||||||
- attachment_guids: The GUIDs of the attachments to send.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- outgoing_message_id: The ID of the outgoing message.
|
|
||||||
"/>
|
|
||||||
</method>
|
|
||||||
|
|
||||||
<method name="NewConversation">
|
|
||||||
<arg type="as" name="handle_ids" direction="in"/>
|
|
||||||
<arg type="s" name="text" direction="in"/>
|
|
||||||
<arg type="as" name="attachment_guids" direction="in"/>
|
|
||||||
|
|
||||||
<arg type="s" name="outgoing_message_id" direction="out"/>
|
|
||||||
|
|
||||||
<annotation name="org.freedesktop.DBus.DocString"
|
|
||||||
value="Sends a message to a new conversation identified by resolved handles.
|
|
||||||
Arguments:
|
|
||||||
- handle_ids: The resolved handles for the new conversation.
|
|
||||||
- text: The text of the message to send.
|
- text: The text of the message to send.
|
||||||
- attachment_guids: The GUIDs of the attachments to send.
|
- attachment_guids: The GUIDs of the attachments to send.
|
||||||
|
|
||||||
|
|||||||
@@ -96,20 +96,12 @@ public class Repository : DBusServiceProxy {
|
|||||||
return returned_messages;
|
return returned_messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string reply(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
|
public string send_message(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
|
||||||
if (dbus_repository == null) {
|
if (dbus_repository == null) {
|
||||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbus_repository.reply(conversation_guid, message, attachment_guids);
|
return dbus_repository.send_message(conversation_guid, message, attachment_guids);
|
||||||
}
|
|
||||||
|
|
||||||
public string new_conversation(string[] handle_ids, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
|
|
||||||
if (dbus_repository == null) {
|
|
||||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbus_repository.new_conversation(handle_ids, message, attachment_guids);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sync_conversation(string conversation_guid) throws DBusServiceProxyError, GLib.Error {
|
public void sync_conversation(string conversation_guid) throws DBusServiceProxyError, GLib.Error {
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class TranscriptContainerView : Adw.Bin
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Repository.get_instance().reply(selected_conversation.guid, body, attachment_guids.to_array());
|
Repository.get_instance().send_message(selected_conversation.guid, body, attachment_guids.to_array());
|
||||||
} catch (Error e) {
|
} catch (Error e) {
|
||||||
GLib.warning("Failed to send message: %s", e.message);
|
GLib.warning("Failed to send message: %s", e.message);
|
||||||
}
|
}
|
||||||
@@ -333,4 +333,4 @@ class UploadedAttachment
|
|||||||
this.upload_guid = upload_guid;
|
this.upload_guid = upload_guid;
|
||||||
this.attachment_guid = attachment_guid;
|
this.attachment_guid = attachment_guid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ struct MessageEntryView: View
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await client.reply(
|
try await client.sendMessage(
|
||||||
conversationId: convo.id,
|
conversationId: convo.id,
|
||||||
message: messageText,
|
message: messageText,
|
||||||
transferGuids: transferGuids
|
transferGuids: transferGuids
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ final class XPCClient
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reply(conversationId: String, message: String, transferGuids: Set<String>) async throws {
|
public func sendMessage(conversationId: String, message: String, transferGuids: Set<String>) async throws {
|
||||||
var args: [String: xpc_object_t] = [:]
|
var args: [String: xpc_object_t] = [:]
|
||||||
args["conversation_id"] = xpcString(conversationId)
|
args["conversation_id"] = xpcString(conversationId)
|
||||||
args["text"] = xpcString(message)
|
args["text"] = xpcString(message)
|
||||||
@@ -142,20 +142,7 @@ final class XPCClient
|
|||||||
args["attachment_guids"] = xpcStringArray(transferGuids)
|
args["attachment_guids"] = xpcStringArray(transferGuids)
|
||||||
}
|
}
|
||||||
|
|
||||||
let req = makeRequest(method: "Reply", arguments: args)
|
let req = makeRequest(method: "SendMessage", arguments: args)
|
||||||
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
|
||||||
}
|
|
||||||
|
|
||||||
public func newConversation(handleIds: Set<String>, message: String, transferGuids: Set<String>) async throws {
|
|
||||||
var args: [String: xpc_object_t] = [:]
|
|
||||||
args["handle_ids"] = xpcStringArray(handleIds)
|
|
||||||
args["text"] = xpcString(message)
|
|
||||||
|
|
||||||
if !transferGuids.isEmpty {
|
|
||||||
args["attachment_guids"] = xpcStringArray(transferGuids)
|
|
||||||
}
|
|
||||||
|
|
||||||
let req = makeRequest(method: "NewConversation", arguments: args)
|
|
||||||
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,3 +411,4 @@ extension xpc_object_t
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
#import "MBIMAuthToken.h"
|
#import "MBIMAuthToken.h"
|
||||||
#import "MBIMUpdateQueue.h"
|
#import "MBIMUpdateQueue.h"
|
||||||
#import "MBIMURLUtilities.h"
|
#import "MBIMURLUtilities.h"
|
||||||
#import "MBIMLogging.h"
|
|
||||||
|
|
||||||
#import <Security/Security.h>
|
#import <Security/Security.h>
|
||||||
#import "HTTPMessage.h"
|
#import "HTTPMessage.h"
|
||||||
@@ -99,10 +98,6 @@
|
|||||||
__block NSObject<HTTPResponse> *response = nil;
|
__block NSObject<HTTPResponse> *response = nil;
|
||||||
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
||||||
MBIMBridgeOperationCompletionBlock completion = ^(NSObject<HTTPResponse> *incomingResponse) {
|
MBIMBridgeOperationCompletionBlock completion = ^(NSObject<HTTPResponse> *incomingResponse) {
|
||||||
if (incomingResponse == nil) {
|
|
||||||
MBIMLogError(@"Operation for %@ %@ completed with a nil response.", method, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
response = incomingResponse;
|
response = incomingResponse;
|
||||||
dispatch_semaphore_signal(sema);
|
dispatch_semaphore_signal(sema);
|
||||||
};
|
};
|
||||||
@@ -129,11 +124,6 @@
|
|||||||
if (requestTimedOut) {
|
if (requestTimedOut) {
|
||||||
response = [_currentOperation cancelAndReturnTimeoutResponse];
|
response = [_currentOperation cancelAndReturnTimeoutResponse];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response == nil) {
|
|
||||||
MBIMLogError(@"Returning fallback 500 for %@ %@ because the operation produced no response.", method, path);
|
|
||||||
response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
#import "IMCore_ClassDump.h"
|
#import "IMCore_ClassDump.h"
|
||||||
#import "IMMessageItem+Encoded.h"
|
#import "IMMessageItem+Encoded.h"
|
||||||
#import "MBIMErrorResponse.h"
|
|
||||||
|
|
||||||
@implementation MBIMSendMessageOperation
|
@implementation MBIMSendMessageOperation
|
||||||
|
|
||||||
@@ -21,228 +20,40 @@
|
|||||||
return @"sendMessage";
|
return @"sendMessage";
|
||||||
}
|
}
|
||||||
|
|
||||||
- (nullable IMChat *)_existingSingleChatForHandle:(IMHandle *)handle registry:(IMChatRegistry *)registry
|
- (IMMessage *)_sendMessage:(NSString *)messageBody toChatWithGUID:(NSString *)chatGUID attachmentGUIDs:(NSArray<NSString *> *)guids
|
||||||
{
|
{
|
||||||
if ([registry respondsToSelector:@selector(existingChatWithHandle:allowAlternativeService:)]) {
|
__block IMMessage *result = nil;
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandle:allowAlternativeService:");
|
|
||||||
return [registry existingChatWithHandle:handle allowAlternativeService:NO];
|
dispatch_sync([[self class] sharedIMAccessQueue], ^{
|
||||||
}
|
IMChat *chat = [[IMChatRegistry sharedInstance] existingChatWithGUID:chatGUID];
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatWithHandle:)]) {
|
// TODO: chat might not be an iMessage chat!
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandle:");
|
IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
|
||||||
return [registry existingChatWithHandle:handle];
|
IMHandle *senderHandle = [iMessageAccount loginIMHandle];
|
||||||
}
|
|
||||||
|
NSAttributedString *replyAttrString = [[NSAttributedString alloc] initWithString:messageBody];
|
||||||
if ([registry respondsToSelector:@selector(existingChatForIMHandle:allowRetargeting:)]) {
|
NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids);
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandle:allowRetargeting:");
|
|
||||||
return [registry existingChatForIMHandle:handle allowRetargeting:NO];
|
IMMessage *reply = [IMMessage fromMeIMHandle:senderHandle
|
||||||
}
|
withText:attrStringWithFileTransfers
|
||||||
|
fileTransferGUIDs:guids
|
||||||
if ([registry respondsToSelector:@selector(existingChatForIMHandle:)]) {
|
flags:(kIMMessageFinished | kIMMessageIsFromMe)];
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandle:");
|
|
||||||
return [registry existingChatForIMHandle:handle];
|
for (NSString *guid in [reply fileTransferGUIDs]) {
|
||||||
}
|
[[IMFileTransferCenter sharedInstance] assignTransfer:guid toHandle:chat.recipient];
|
||||||
|
|
||||||
MBIMLogError(@"IMChatRegistry does not support any known single-handle existing chat lookup selector.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable IMChat *)_createSingleChatForHandle:(IMHandle *)handle registry:(IMChatRegistry *)registry
|
|
||||||
{
|
|
||||||
if ([registry respondsToSelector:@selector(chatWithHandle:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatWithHandle:");
|
|
||||||
return [registry chatWithHandle:handle];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(chatForIMHandle:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandle:");
|
|
||||||
return [registry chatForIMHandle:handle];
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogError(@"IMChatRegistry does not support any known single-handle chat creation selector.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable IMChat *)_existingGroupChatForHandles:(NSArray<IMHandle *> *)handles registry:(IMChatRegistry *)registry
|
|
||||||
{
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatWithHandles:allowAlternativeService:groupID:displayName:joinedChatsOnly:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:allowAlternativeService:groupID:displayName:joinedChatsOnly:");
|
|
||||||
return [registry existingChatWithHandles:handles
|
|
||||||
allowAlternativeService:NO
|
|
||||||
groupID:nil
|
|
||||||
displayName:nil
|
|
||||||
joinedChatsOnly:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatWithHandles:allowAlternativeService:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:allowAlternativeService:");
|
|
||||||
return [registry existingChatWithHandles:handles allowAlternativeService:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatWithHandles:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:");
|
|
||||||
return [registry existingChatWithHandles:handles];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatForIMHandles:allowRetargeting:groupID:displayName:joinedChatsOnly:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:allowRetargeting:groupID:displayName:joinedChatsOnly:");
|
|
||||||
return [registry existingChatForIMHandles:handles
|
|
||||||
allowRetargeting:NO
|
|
||||||
groupID:nil
|
|
||||||
displayName:nil
|
|
||||||
joinedChatsOnly:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatForIMHandles:allowRetargeting:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:allowRetargeting:");
|
|
||||||
return [registry existingChatForIMHandles:handles allowRetargeting:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(existingChatForIMHandles:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:");
|
|
||||||
return [registry existingChatForIMHandles:handles];
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogError(@"IMChatRegistry does not support any known multi-handle existing chat lookup selector.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable IMChat *)_createGroupChatForHandles:(NSArray<IMHandle *> *)handles registry:(IMChatRegistry *)registry
|
|
||||||
{
|
|
||||||
if ([registry respondsToSelector:@selector(chatWithHandles:displayName:joinedChatsOnly:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatWithHandles:displayName:joinedChatsOnly:");
|
|
||||||
return [registry chatWithHandles:handles displayName:nil joinedChatsOnly:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(chatWithHandles:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatWithHandles:");
|
|
||||||
return [registry chatWithHandles:handles];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(chatForIMHandles:displayName:joinedChatsOnly:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandles:displayName:joinedChatsOnly:");
|
|
||||||
return [registry chatForIMHandles:handles displayName:nil joinedChatsOnly:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([registry respondsToSelector:@selector(chatForIMHandles:)]) {
|
|
||||||
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandles:");
|
|
||||||
return [registry chatForIMHandles:handles];
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogError(@"IMChatRegistry does not support any known multi-handle chat creation selector.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable IMChat *)_chatForHandleIDs:(NSArray<NSString *> *)handleIDs registry:(IMChatRegistry *)registry
|
|
||||||
{
|
|
||||||
MBIMLogInfo(@"Resolving send target for handles: %@", handleIDs);
|
|
||||||
|
|
||||||
IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
|
|
||||||
if (!iMessageAccount) {
|
|
||||||
MBIMLogError(@"Unable to find an iMessage account for message send.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableArray<IMHandle *> *handles = [NSMutableArray arrayWithCapacity:[handleIDs count]];
|
|
||||||
for (NSString *handleID in handleIDs) {
|
|
||||||
IMHandle *handle = [iMessageAccount imHandleWithID:handleID];
|
|
||||||
if (!handle) {
|
|
||||||
MBIMLogError(@"Couldn't resolve IMHandle for id %@", handleID);
|
|
||||||
return nil;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[handles addObject:handle];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([handles count] == 1) {
|
|
||||||
IMHandle *handle = [handles firstObject];
|
|
||||||
IMChat *chat = [self _existingSingleChatForHandle:handle registry:registry];
|
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
chat = [self _createSingleChatForHandle:handle registry:registry];
|
MBIMLogInfo(@"Chat does not exist: %@", chatGUID);
|
||||||
}
|
|
||||||
|
|
||||||
if (chat) {
|
|
||||||
MBIMLogInfo(@"Resolved send target %@ to chat %@", [handle ID], [chat guid] ?: @"<unknown>");
|
|
||||||
} else {
|
} else {
|
||||||
MBIMLogError(@"Unable to locate or create chat for handle %@", [handle ID]);
|
result = reply;
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[chat sendMessage:reply];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
IMChat *chat = [self _existingGroupChatForHandles:handles registry:registry];
|
|
||||||
if (!chat) {
|
|
||||||
chat = [self _createGroupChatForHandles:handles registry:registry];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chat) {
|
|
||||||
MBIMLogInfo(@"Resolved handles %@ to chat %@", handleIDs, [chat guid] ?: @"<unknown>");
|
|
||||||
} else {
|
|
||||||
MBIMLogError(@"Unable to locate or create chat for handles %@", handleIDs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable NSDictionary *)_sendMessage:(NSString *)messageBody toChat:(IMChat *)chat attachmentGUIDs:(NSArray<NSString *> *)guids includeConversationGUID:(BOOL)includeConversationGUID
|
|
||||||
{
|
|
||||||
if (!chat) {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *chatGUID = [chat guid];
|
|
||||||
if (!chatGUID) {
|
|
||||||
chatGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogInfo(@"Preparing sendMessage for chat %@ (bodyLength=%lu attachmentCount=%lu)", chatGUID ?: @"<unknown>", (unsigned long)[messageBody length], (unsigned long)[guids count]);
|
|
||||||
|
|
||||||
IMAccount *sendingAccount = [chat account];
|
|
||||||
if (!sendingAccount) {
|
|
||||||
sendingAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
|
|
||||||
}
|
|
||||||
|
|
||||||
IMHandle *senderHandle = [sendingAccount loginIMHandle];
|
|
||||||
if (!senderHandle) {
|
|
||||||
MBIMLogError(@"Unable to determine sender handle for message send.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSAttributedString *replyAttrString = [[NSAttributedString alloc] initWithString:messageBody];
|
|
||||||
NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids);
|
|
||||||
|
|
||||||
IMMessage *reply = [IMMessage fromMeIMHandle:senderHandle
|
|
||||||
withText:attrStringWithFileTransfers
|
|
||||||
fileTransferGUIDs:guids
|
|
||||||
flags:(kIMMessageFinished | kIMMessageIsFromMe)];
|
|
||||||
|
|
||||||
for (NSString *guid in [reply fileTransferGUIDs]) {
|
|
||||||
[[IMFileTransferCenter sharedInstance] assignTransfer:guid toMessage:reply account:sendingAccount];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSDictionary *replyRepresentation = [reply mbim_dictionaryRepresentation];
|
|
||||||
if (![replyRepresentation isKindOfClass:[NSDictionary class]]) {
|
|
||||||
MBIMLogError(@"Unable to encode sent message for chat %@", chatGUID ?: @"<unknown>");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableDictionary *result = [replyRepresentation mutableCopy];
|
|
||||||
if (includeConversationGUID) {
|
|
||||||
NSString *conversationGUID = chatGUID;
|
|
||||||
if (!conversationGUID) {
|
|
||||||
conversationGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conversationGUID) {
|
|
||||||
result[@"conversationGUID"] = conversationGUID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogInfo(@"Dispatching IMCore send for chat %@", chatGUID ?: @"<unknown>");
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
[chat sendMessage:reply];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,112 +79,41 @@
|
|||||||
|
|
||||||
- (void)main
|
- (void)main
|
||||||
{
|
{
|
||||||
__block NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
|
NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
|
||||||
|
|
||||||
NSError *error = nil;
|
NSError *error = nil;
|
||||||
NSDictionary *args = [NSJSONSerialization JSONObjectWithData:self.requestBodyData options:0 error:&error];
|
NSDictionary *args = [NSJSONSerialization JSONObjectWithData:self.requestBodyData options:0 error:&error];
|
||||||
if (error || args.count == 0) {
|
if (error || args.count == 0) {
|
||||||
MBIMLogError(@"Unable to parse sendMessage request body: %@", error);
|
|
||||||
self.serverCompletionBlock(response);
|
self.serverCompletionBlock(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *guid = [args objectForKey:@"guid"];
|
NSString *guid = [args objectForKey:@"guid"];
|
||||||
NSString *messageBody = [args objectForKey:@"body"];
|
NSString *messageBody = [args objectForKey:@"body"];
|
||||||
NSArray *rawHandleIDs = [args objectForKey:@"handleIDs"];
|
if (!guid || !messageBody) {
|
||||||
BOOL hasGUID = [guid isKindOfClass:[NSString class]] && [guid length] > 0;
|
|
||||||
BOOL hasHandleIDs = [rawHandleIDs isKindOfClass:[NSArray class]] && [rawHandleIDs count] > 0;
|
|
||||||
|
|
||||||
if (![messageBody isKindOfClass:[NSString class]] || (!hasGUID && !hasHandleIDs) || (hasGUID && hasHandleIDs)) {
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"sendMessage requires body and exactly one of guid or handleIDs."];
|
|
||||||
self.serverCompletionBlock(response);
|
self.serverCompletionBlock(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSMutableArray<NSString *> *handleIDs = [NSMutableArray array];
|
// tapbacks
|
||||||
if (hasHandleIDs) {
|
#if 0
|
||||||
for (id handleID in rawHandleIDs) {
|
IMMessage *acknowledgment = [IMMessage instantMessageWithAssociatedMessageContent: /* [NSString stringWithFormat:@"%@ \"%%@\"", tapbackAction] */
|
||||||
if ([handleID isKindOfClass:[NSString class]] && [handleID length] > 0) {
|
flags:0
|
||||||
[handleIDs addObject:handleID];
|
associatedMessageGUID:guid
|
||||||
}
|
associatedMessageType:IMAssociatedMessageTypeAcknowledgmentHeart
|
||||||
}
|
associatedMessageRange:[imMessage messagePartRange]
|
||||||
|
messageSummaryInfo:[self adjustMessageSummaryInfoForSending:message]
|
||||||
handleIDs = [[[NSOrderedSet orderedSetWithArray:handleIDs] array] mutableCopy];
|
threadIdentifier:[imMessage threadIdentifier]];
|
||||||
if ([handleIDs count] == 0) {
|
#endif
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"No valid handle IDs provided."];
|
|
||||||
self.serverCompletionBlock(response);
|
NSArray *transferGUIDs = [args objectForKey:@"fileTransferGUIDs"];
|
||||||
return;
|
if (!transferGUIDs) {
|
||||||
}
|
transferGUIDs = @[];
|
||||||
}
|
}
|
||||||
|
|
||||||
NSArray *rawTransferGUIDs = [args objectForKey:@"fileTransferGUIDs"];
|
IMMessage *result = [self _sendMessage:messageBody toChatWithGUID:guid attachmentGUIDs:transferGUIDs];
|
||||||
NSMutableArray<NSString *> *transferGUIDs = [NSMutableArray array];
|
if (result) {
|
||||||
if ([rawTransferGUIDs isKindOfClass:[NSArray class]]) {
|
response = [MBIMJSONDataResponse responseWithJSONObject:[result mbim_dictionaryRepresentation]];
|
||||||
for (id transferGUID in rawTransferGUIDs) {
|
|
||||||
if ([transferGUID isKindOfClass:[NSString class]] && [transferGUID length] > 0) {
|
|
||||||
[transferGUIDs addObject:transferGUID];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MBIMLogInfo(@"sendMessage request received. guid=%@ handleIDs=%@ bodyLength=%lu attachmentGUIDs=%@", hasGUID ? guid : @"<none>", handleIDs, (unsigned long)[messageBody length], transferGUIDs);
|
|
||||||
|
|
||||||
@try {
|
|
||||||
dispatch_sync([[self class] sharedIMAccessQueue], ^{
|
|
||||||
IMChatRegistry *registry = [IMChatRegistry sharedInstance];
|
|
||||||
IMChat *chat = nil;
|
|
||||||
BOOL includeConversationGUID = NO;
|
|
||||||
|
|
||||||
if (hasGUID) {
|
|
||||||
MBIMLogInfo(@"sendMessage targeting existing conversation %@", guid);
|
|
||||||
chat = [registry existingChatWithGUID:guid];
|
|
||||||
if (!chat) {
|
|
||||||
MBIMLogError(@"Chat does not exist for guid %@", guid);
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Chat does not exist for the provided guid."];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
MBIMLogInfo(@"sendMessage targeting handles %@", handleIDs);
|
|
||||||
chat = [self _chatForHandleIDs:handleIDs registry:registry];
|
|
||||||
includeConversationGUID = YES;
|
|
||||||
if (!chat) {
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to create or locate a chat for the provided handles."];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *resolvedChatGUID = [chat guid];
|
|
||||||
if (!resolvedChatGUID) {
|
|
||||||
resolvedChatGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
|
|
||||||
}
|
|
||||||
MBIMLogInfo(@"sendMessage resolved target chat %@", resolvedChatGUID ?: @"<unknown>");
|
|
||||||
|
|
||||||
NSDictionary *result = [self _sendMessage:messageBody
|
|
||||||
toChat:chat
|
|
||||||
attachmentGUIDs:transferGUIDs
|
|
||||||
includeConversationGUID:includeConversationGUID];
|
|
||||||
if (!result) {
|
|
||||||
MBIMLogError(@"sendMessage failed before a response payload could be encoded for chat %@", resolvedChatGUID ?: @"<unknown>");
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to construct sent message response."];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSObject<HTTPResponse> *jsonResponse = [MBIMJSONDataResponse responseWithJSONObject:result];
|
|
||||||
if (jsonResponse) {
|
|
||||||
response = jsonResponse;
|
|
||||||
} else {
|
|
||||||
MBIMLogError(@"Unable to encode sendMessage JSON response for chat %@", resolvedChatGUID ?: @"<unknown>");
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to encode sendMessage response."];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} @catch (NSException *exception) {
|
|
||||||
MBIMLogError(@"Unhandled exception during sendMessage. name=%@ reason=%@ userInfo=%@", exception.name, exception.reason, exception.userInfo);
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unhandled exception while sending message. Check server logs."];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response == nil) {
|
|
||||||
MBIMLogError(@"sendMessage completed without producing a response. guid=%@ handleIDs=%@", hasGUID ? guid : @"<none>", handleIDs);
|
|
||||||
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"sendMessage did not produce a response. Check server logs."];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.serverCompletionBlock(response);
|
self.serverCompletionBlock(response);
|
||||||
|
|||||||
Reference in New Issue
Block a user