From 40e0f8f5d8c3e480f01a2d71b6a493247adcb77f Mon Sep 17 00:00:00 2001 From: dec05eba Date: Mon, 21 Sep 2020 03:49:17 +0200 Subject: Initial matrix support --- src/plugins/Matrix.cpp | 687 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 src/plugins/Matrix.cpp (limited to 'src/plugins/Matrix.cpp') diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp new file mode 100644 index 0000000..77e295e --- /dev/null +++ b/src/plugins/Matrix.cpp @@ -0,0 +1,687 @@ +#include "../../plugins/Matrix.hpp" +#include "../../include/Storage.hpp" +#include +#include +#include +#include + +// TODO: Update avatar/display name when its changed in the room/globally. +// Send read receipt to server and receive notifications in /sync and show the notifications. +// Delete messages. +// Edit messages. +// Show embedded images/videos. +// TODO: Verify if buffer of size 512 is enough for endpoints + +namespace QuickMedia { + Matrix::Matrix() : Plugin("matrix") { + + } + + PluginResult Matrix::get_cached_sync(BodyItems &result_items) { + /* + Path sync_cache_path = get_cache_dir().join(name).join("sync.json"); + Json::Value root; + if(!read_file_as_json(sync_cache_path, root)) + return PluginResult::ERR; + return sync_response_to_body_items(root, result_items); + */ + (void)result_items; + return PluginResult::OK; + } + + PluginResult Matrix::sync() { + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token }, + { "-m", "35" } + }; + + std::string server_response; + // timeout=30000, filter=0. First sync should be without filter and timeout=0, then all other sync should be with timeout=30000 and filter=0. + // GET https://glowers.club/_matrix/client/r0/user/%40dec05eba%3Aglowers.club/filter/0 first to check if the filter is available + // and if lazy load members is available and get limit to use with https://glowers.club/_matrix/client/r0/rooms/!oSXkiqBKooDcZsmiGO%3Aglowers.club/ + // when first launching the client. This call to /rooms/ should be called before /sync/, when accessing a room. But only the first time + // (for the session). + + // Note: the first sync call with always exclude since= (next_batch) because we want to receive the latest messages in a room, + // which is important if we for example login to matrix after having not been online for several days and there are many new messages. + // We should be shown the latest messages first and if the user wants to see older messages then they should scroll up. + // Note: missed mentions are received in /sync and they will remain in new /sync unless we send a read receipt that we have read them. + + char url[512]; + if(next_batch.empty()) + snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?timeout=0", homeserver.c_str()); + else + snprintf(url, sizeof(url), "%s/_matrix/client/r0/sync?timeout=30000&since=%s", homeserver.c_str(), next_batch.c_str()); + + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix sync response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + PluginResult result = sync_response_to_body_items(json_root); + if(result != PluginResult::OK) + return result; + + const Json::Value &next_batch_json = json_root["next_batch"]; + if(next_batch_json.isString()) { + next_batch = next_batch_json.asString(); + fprintf(stderr, "Matrix: next batch: %s\n", next_batch.c_str()); + } else { + fprintf(stderr, "Matrix: missing next batch\n"); + } + + // TODO: Only create the first time sync is called? + /* + Path sync_cache_path = get_cache_dir().join(name); + if(create_directory_recursive(sync_cache_path) == 0) { + sync_cache_path.join("sync.json"); + if(!save_json_to_file_atomic(sync_cache_path, json_root)) { + fprintf(stderr, "Warning: failed to save sync response to %s\n", sync_cache_path.data.c_str()); + } + } else { + fprintf(stderr, "Warning: failed to create directory: %s\n", sync_cache_path.data.c_str()); + } + */ + + return PluginResult::OK; + } + + PluginResult Matrix::get_joined_rooms(BodyItems &result_items) { + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string server_response; + if(download_to_string(homeserver + "/_matrix/client/r0/joined_rooms", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix joined rooms response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &joined_rooms_json = json_root["joined_rooms"]; + if(!joined_rooms_json.isArray()) + return PluginResult::ERR; + + for(const Json::Value &room_id_json : joined_rooms_json) { + if(!room_id_json.isString()) + continue; + + std::string room_id_str = room_id_json.asString(); + + auto room_it = room_data_by_id.find(room_id_str); + if(room_it == room_data_by_id.end()) { + auto room_data = std::make_unique(); + room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); + fprintf(stderr, "Missing room %s from /sync, adding in joined_rooms\n", room_id_str.c_str()); + } + + auto body_item = std::make_unique(std::move(room_id_str)); + //body_item->url = ""; + result_items.push_back(std::move(body_item)); + } + + return PluginResult::OK; + } + + PluginResult Matrix::get_room_messages(const std::string &room_id, size_t start_index, BodyItems &result_items, size_t &num_new_messages) { + num_new_messages = 0; + + auto room_it = room_data_by_id.find(room_id); + if(room_it == room_data_by_id.end()) { + fprintf(stderr, "Error: no such room: %s\n", room_id.c_str()); + return PluginResult::ERR; + } + + if(!room_it->second->initial_fetch_finished) { + PluginResult result = load_initial_room_data(room_id, room_it->second.get()); + if(result == PluginResult::OK) { + room_it->second->initial_fetch_finished = true; + } else { + fprintf(stderr, "Initial sync failed for room: %s\n", room_id.c_str()); + return result; + } + } + + // This will happen if there are no new messages + if(start_index >= room_it->second->messages.size()) + return PluginResult::OK; + + num_new_messages = room_it->second->messages.size() - start_index; + + size_t prev_user_id = -1; + for(auto it = room_it->second->messages.begin() + start_index, end = room_it->second->messages.end(); it != end; ++it) { + const UserInfo &user_info = room_it->second->user_info[it->user_id]; + if(it->user_id == prev_user_id) { + assert(!result_items.empty()); + result_items.back()->append_description("\n"); + result_items.back()->append_description(it->msg); + } else { + auto body_item = std::make_unique(""); + body_item->author = user_info.display_name; + body_item->set_description(it->msg); + body_item->thumbnail_url = user_info.avatar_url; + result_items.push_back(std::move(body_item)); + prev_user_id = it->user_id; + } + } + + return PluginResult::OK; + } + + PluginResult Matrix::sync_response_to_body_items(const Json::Value &root) { + if(!root.isObject()) + return PluginResult::ERR; + + const Json::Value &rooms_json = root["rooms"]; + if(!rooms_json.isObject()) + return PluginResult::OK; + + const Json::Value &join_json = rooms_json["join"]; + if(!join_json.isObject()) + return PluginResult::OK; + + for(Json::Value::const_iterator it = join_json.begin(); it != join_json.end(); ++it) { + if(!it->isObject()) + continue; + + Json::Value room_id = it.key(); + if(!room_id.isString()) + continue; + + std::string room_id_str = room_id.asString(); + + auto room_it = room_data_by_id.find(room_id_str); + if(room_it == room_data_by_id.end()) { + auto room_data = std::make_unique(); + room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); + room_it = room_data_by_id.find(room_id_str); // TODO: Get iterator from above insert + } + + const Json::Value &state_json = (*it)["state"]; + if(!state_json.isObject()) + continue; + + const Json::Value &events_json = state_json["events"]; + events_add_user_info(events_json, room_it->second.get()); + } + + for(Json::Value::const_iterator it = join_json.begin(); it != join_json.end(); ++it) { + if(!it->isObject()) + continue; + + Json::Value room_id = it.key(); + if(!room_id.isString()) + continue; + + std::string room_id_str = room_id.asString(); + + auto room_it = room_data_by_id.find(room_id_str); + if(room_it == room_data_by_id.end()) { + auto room_data = std::make_unique(); + room_data_by_id.insert(std::make_pair(room_id_str, std::move(room_data))); + room_it = room_data_by_id.find(room_id_str); // TODO: Get iterator from above insert + } + + const Json::Value &timeline_json = (*it)["timeline"]; + if(!timeline_json.isObject()) + continue; + + // This may be non-existent if this is the first event in the room + const Json::Value &prev_batch_json = timeline_json["prev_batch"]; + if(prev_batch_json.isString()) + room_it->second->prev_batch = prev_batch_json.asString(); + + const Json::Value &events_json = timeline_json["events"]; + events_add_messages(events_json, room_it->second.get(), MessageDirection::AFTER); + } + + return PluginResult::OK; + } + + void Matrix::events_add_user_info(const Json::Value &events_json, RoomData *room_data) { + if(!events_json.isArray()) + return; + + for(const Json::Value &event_item_json : events_json) { + if(!event_item_json.isObject()) + continue; + + const Json::Value &type_json = event_item_json["type"]; + if(!type_json.isString() || strcmp(type_json.asCString(), "m.room.member") != 0) + continue; + + const Json::Value &sender_json = event_item_json["sender"]; + if(!sender_json.isString()) + continue; + + const Json::Value &content_json = event_item_json["content"]; + if(!content_json.isObject()) + continue; + + const Json::Value &membership_json = content_json["membership"]; + if(!membership_json.isString() || strcmp(membership_json.asCString(), "join") != 0) + continue; + + const Json::Value &avatar_url_json = content_json["avatar_url"]; + if(!avatar_url_json.isString()) + continue; + + const Json::Value &display_name_json = content_json["displayname"]; + if(!display_name_json.isString()) + continue; + + std::string sender_json_str = sender_json.asString(); + auto user_it = room_data->user_info_by_user_id.find(sender_json_str); + if(user_it != room_data->user_info_by_user_id.end()) + continue; + + UserInfo user_info; + user_info.avatar_url = avatar_url_json.asString(); + if(user_info.avatar_url.size() >= 6) + user_info.avatar_url.erase(user_info.avatar_url.begin(), user_info.avatar_url.begin() + 6); + // TODO: What if the user hasn't selected an avatar? + user_info.avatar_url = homeserver + "/_matrix/media/r0/thumbnail/" + user_info.avatar_url + "?width=32&height=32&method=crop"; + user_info.display_name = display_name_json.asString(); + room_data->user_info.push_back(std::move(user_info)); + room_data->user_info_by_user_id.insert(std::make_pair(sender_json_str, room_data->user_info.size() - 1)); + } + } + + void Matrix::events_add_messages(const Json::Value &events_json, RoomData *room_data, MessageDirection message_dir) { + if(!events_json.isArray()) + return; + + std::vector new_messages; + + for(const Json::Value &event_item_json : events_json) { + if(!event_item_json.isObject()) + continue; + + const Json::Value &type_json = event_item_json["type"]; + if(!type_json.isString() || strcmp(type_json.asCString(), "m.room.message") != 0) + continue; + + const Json::Value &sender_json = event_item_json["sender"]; + if(!sender_json.isString()) + continue; + + std::string sender_json_str = sender_json.asString(); + + const Json::Value &content_json = event_item_json["content"]; + if(!content_json.isObject()) + continue; + + const Json::Value &content_type = content_json["msgtype"]; + if(!content_type.isString() || strcmp(content_type.asCString(), "m.text") != 0) + continue; + + const Json::Value &body_json = content_json["body"]; + if(!body_json.isString()) + continue; + + auto user_it = room_data->user_info_by_user_id.find(sender_json_str); + if(user_it == room_data->user_info_by_user_id.end()) { + fprintf(stderr, "Warning: skipping unknown user: %s\n", sender_json_str.c_str()); + continue; + } + + Message message; + message.user_id = user_it->second; + message.msg = body_json.asString(); + new_messages.push_back(std::move(message)); + } + + if(message_dir == MessageDirection::BEFORE) { + room_data->messages.insert(room_data->messages.begin(), new_messages.rbegin(), new_messages.rend()); + } else if(message_dir == MessageDirection::AFTER) { + room_data->messages.insert(room_data->messages.end(), new_messages.begin(), new_messages.end()); + } + } + + PluginResult Matrix::load_initial_room_data(const std::string &room_id, RoomData *room_data) { + std::string from = room_data->prev_batch; + if(from.empty()) { + fprintf(stderr, "Info: missing previous batch for room: %s, using /sync next batch\n", room_id.c_str()); + from = next_batch; + if(from.empty()) { + fprintf(stderr, "Error: missing next batch!\n"); + return PluginResult::OK; + } + } + + Json::Value request_data(Json::objectValue); + request_data["lazy_load_members"] = true; + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string filter = url_param_encode(Json::writeString(builder, request_data)); + + char url[512]; + snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/messages?from=%s&limit=20&dir=b&filter=%s", homeserver.c_str(), room_id.c_str(), from.c_str(), filter.c_str()); + fprintf(stderr, "load initial room data, url: |%s|\n", url); + + std::string server_response; + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix /rooms//messages/ response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &state_json = json_root["state"]; + events_add_user_info(state_json, room_data); + + const Json::Value &chunk_json = json_root["chunk"]; + events_add_messages(chunk_json, room_data, MessageDirection::BEFORE); + + return PluginResult::OK; + } + + SearchResult Matrix::search(const std::string&, BodyItems&) { + return SearchResult::OK; + } + + static bool generate_random_characters(char *buffer, int buffer_size) { + int fd = open("/dev/urandom", O_RDONLY); + if(fd == -1) { + perror("/dev/urandom"); + return false; + } + + if(read(fd, buffer, buffer_size) < buffer_size) { + fprintf(stderr, "Failed to read %d bytes from /dev/urandom\n", buffer_size); + close(fd); + return false; + } + + close(fd); + return true; + } + + static std::string random_characters_to_readable_string(const char *buffer, int buffer_size) { + std::ostringstream result; + result << std::hex; + for(int i = 0; i < buffer_size; ++i) + result << (int)(unsigned char)buffer[i]; + return result.str(); + } + + PluginResult Matrix::post_message(const std::string &room_id, const std::string &text) { + char random_characters[18]; + if(!generate_random_characters(random_characters, sizeof(random_characters))) + return PluginResult::ERR; + + std::string random_readable_chars = random_characters_to_readable_string(random_characters, sizeof(random_characters)); + + std::string formatted_body; + bool contains_formatted_text = false; + string_split(text, '\n', [&formatted_body, &contains_formatted_text](const char *str, size_t size){ + if(size > 0 && str[0] == '>') { + std::string line(str, size); + html_escape_sequences(line); + formatted_body += ""; + formatted_body += line; + formatted_body += ""; + contains_formatted_text = true; + } else { + formatted_body.append(str, size); + } + return true; + }); + + Json::Value request_data(Json::objectValue); + request_data["msgtype"] = "m.text"; + request_data["body"] = text; + if(contains_formatted_text) { + request_data["format"] = "org.matrix.custom.html"; + request_data["formatted_body"] = std::move(formatted_body); + } + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "PUT" }, + { "-H", "content-type: application/json" }, + { "-H", "Authorization: Bearer " + access_token }, + { "--data-binary", Json::writeString(builder, request_data) } + }; + + char url[512]; + snprintf(url, sizeof(url), "%s/_matrix/client/r0/rooms/%s/send/m.room.message/m%ld.%.*s", homeserver.c_str(), room_id.c_str(), time(NULL), (int)random_readable_chars.size(), random_readable_chars.c_str()); + fprintf(stderr, "Post message to |%s|\n", url); + + std::string server_response; + if(download_to_string(url, server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) + return PluginResult::NET_ERR; + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix post message response parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &event_id_json = json_root["event_id"]; + if(!event_id_json.isString()) + return PluginResult::ERR; + + fprintf(stderr, "Matrix post message, response event id: %s\n", event_id_json.asCString()); + return PluginResult::OK; + } + + static std::string parse_login_error_response(std::string json_str) { + if(json_str.empty()) + return "Unknown error"; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&json_str[0], &json_str[json_str.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix login response parse error: %s\n", json_errors.c_str()); + return json_str; + } + + if(!json_root.isObject()) + return json_str; + + const Json::Value &errcode_json = json_root["errcode"]; + // Yes, matrix is retarded and returns M_NOT_JSON error code when username/password is incorrect + if(errcode_json.isString() && strcmp(errcode_json.asCString(), "M_NOT_JSON") == 0) + return "Incorrect username or password"; + + return json_str; + } + + // Returns empty string on error + static std::string extract_homeserver_from_user_id(const std::string &user_id) { + size_t index = user_id.find(':'); + if(index == std::string::npos) + return ""; + return user_id.substr(index + 1); + } + + PluginResult Matrix::login(const std::string &username, const std::string &password, const std::string &homeserver, std::string &err_msg) { + // TODO: this is deprecated but not all homeservers have the new version. + // When this is removed from future version then switch to the new login method (identifier object with the username). + Json::Value request_data(Json::objectValue); + request_data["type"] = "m.login.password"; + request_data["user"] = username; + request_data["password"] = password; + + Json::StreamWriterBuilder builder; + builder["commentStyle"] = "None"; + builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(builder, request_data) } + }; + + std::string server_response; + if(download_to_string(homeserver + "/_matrix/client/r0/login", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) { + err_msg = parse_login_error_response(std::move(server_response)); + return PluginResult::NET_ERR; + } + + if(server_response.empty()) + return PluginResult::ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&server_response[0], &server_response[server_response.size()], &json_root, &json_errors)) { + err_msg = "Matrix login response parse error: " + json_errors; + return PluginResult::ERR; + } + + if(!json_root.isObject()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + const Json::Value &user_id_json = json_root["user_id"]; + if(!user_id_json.isString()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + const Json::Value &access_token_json = json_root["access_token"]; + if(!access_token_json.isString()) { + err_msg = "Failed to parse matrix login response"; + return PluginResult::ERR; + } + + std::string user_id = user_id_json.asString(); + + std::string homeserver_response = extract_homeserver_from_user_id(user_id); + if(homeserver_response.empty()) { + err_msg = "Missing homeserver in user id, user id: " + user_id; + return PluginResult::ERR; + } + + this->user_id = std::move(user_id); + this->access_token = access_token_json.asString(); + this->homeserver = "https://" + std::move(homeserver_response); + + // TODO: Handle well_known field. The spec says clients SHOULD handle it if its provided + + Path session_path = get_storage_dir().join(name); + if(create_directory_recursive(session_path) == 0) { + session_path.join("session.json"); + if(!save_json_to_file_atomic(session_path, json_root)) { + fprintf(stderr, "Warning: failed to save login response to %s\n", session_path.data.c_str()); + } + } else { + fprintf(stderr, "Warning: failed to create directory: %s\n", session_path.data.c_str()); + } + + return PluginResult::OK; + } + + PluginResult Matrix::load_and_verify_cached_session() { + Path session_path = get_storage_dir().join(name).join("session.json"); + std::string session_json_content; + if(file_get_content(session_path, session_json_content) != 0) { + fprintf(stderr, "Info: failed to read matrix session from %s. Either its missing or we failed to read the file\n", session_path.data.c_str()); + return PluginResult::ERR; + } + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(&session_json_content[0], &session_json_content[session_json_content.size()], &json_root, &json_errors)) { + fprintf(stderr, "Matrix cached session parse error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + const Json::Value &user_id_json = json_root["user_id"]; + if(!user_id_json.isString()) { + fprintf(stderr, "Failed to parse matrix cached session response\n"); + return PluginResult::ERR; + } + + const Json::Value &access_token_json = json_root["access_token"]; + if(!access_token_json.isString()) { + fprintf(stderr, "Failed to parse matrix cached session response\n"); + return PluginResult::ERR; + } + + std::string user_id = user_id_json.asString(); + std::string access_token = access_token_json.asString(); + + std::string homeserver = extract_homeserver_from_user_id(user_id); + if(homeserver.empty()) { + fprintf(stderr, "Missing homeserver in user id, user id: %s\n", user_id.c_str()); + return PluginResult::ERR; + } + + std::vector additional_args = { + { "-H", "Authorization: Bearer " + access_token } + }; + + std::string server_response; + // We want to make any request to the server that can verify that our token is still valid, doesn't matter which call + if(download_to_string("https://" + homeserver + "/_matrix/client/r0/account/whoami", server_response, std::move(additional_args), use_tor, true) != DownloadResult::OK) { + fprintf(stderr, "Matrix whoami response: %s\n", server_response.c_str()); + return PluginResult::NET_ERR; + } + + this->user_id = std::move(user_id); + this->access_token = std::move(access_token); + this->homeserver = "https://" + homeserver; + return PluginResult::OK; + } +} \ No newline at end of file -- cgit v1.2.3