working bridge
This commit is contained in:
commit
7a05a87e72
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.vs/
|
||||||
|
*.o
|
||||||
|
*.swp
|
||||||
|
~*
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
cmake-build-debug/
|
||||||
|
cmake-build-debugandtest/
|
||||||
|
cmake-build-release/
|
||||||
|
*.stackdump
|
||||||
|
*.coredump
|
||||||
|
compile_commands.json
|
||||||
|
/build*
|
||||||
|
/result*
|
||||||
|
.clangd
|
||||||
|
.cache
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
CMakeLists.txt.user*
|
||||||
|
CMakeCache.txt
|
||||||
|
|
||||||
|
*.tox
|
||||||
|
imgui.ini
|
70
CMakeLists.txt
Normal file
70
CMakeLists.txt
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
|
||||||
|
|
||||||
|
# cmake setup begin
|
||||||
|
project(solanaceae_bridge)
|
||||||
|
|
||||||
|
if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||||
|
set(SOLANACEAE_BRIDGE_STANDALONE ON)
|
||||||
|
else()
|
||||||
|
set(SOLANACEAE_BRIDGE_STANDALONE OFF)
|
||||||
|
endif()
|
||||||
|
message("II SOLANACEAE_BRIDGE_STANDALONE " ${SOLANACEAE_BRIDGE_STANDALONE})
|
||||||
|
|
||||||
|
option(SOLANACEAE_BRIDGE_BUILD_PLUGINS "Build the bridge plugin" ${SOLANACEAE_BRIDGE_STANDALONE})
|
||||||
|
|
||||||
|
if (SOLANACEAE_BRIDGE_STANDALONE)
|
||||||
|
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
# defaulting to debug mode, if not specified
|
||||||
|
if(NOT CMAKE_BUILD_TYPE)
|
||||||
|
set(CMAKE_BUILD_TYPE "Debug")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# setup my vim ycm :D
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
|
# more paths
|
||||||
|
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
||||||
|
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||||
|
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# external libs
|
||||||
|
add_subdirectory(./external) # before increasing warn levels, sad :(
|
||||||
|
|
||||||
|
if (SOLANACEAE_BRIDGE_STANDALONE)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
# bump up warning levels appropriately for clang, gcc & msvc
|
||||||
|
if (${CMAKE_CXX_COMPILER_ID} STREQUAL "GNU" OR ${CMAKE_CXX_COMPILER_ID} STREQUAL "Clang")
|
||||||
|
add_compile_options(
|
||||||
|
-Wall -Wextra # Reasonable and standard
|
||||||
|
-Wpedantic # Warn if non-standard C++ is used
|
||||||
|
-Wunused # Warn on anything being unused
|
||||||
|
#-Wconversion # Warn on type conversions that may lose data
|
||||||
|
#-Wsign-conversion # Warn on sign conversions
|
||||||
|
-Wshadow # Warn if a variable declaration shadows one from a parent context
|
||||||
|
)
|
||||||
|
|
||||||
|
if (NOT WIN32)
|
||||||
|
#link_libraries(-fsanitize=address,undefined)
|
||||||
|
#link_libraries(-fsanitize=undefined)
|
||||||
|
endif()
|
||||||
|
elseif (${CMAKE_CXX_COMPILER_ID} STREQUAL "MSVC")
|
||||||
|
if (CMAKE_CXX_FLAGS MATCHES "/W[0-4]")
|
||||||
|
string(REGEX REPLACE "/W[0-4]" "/W4" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
|
||||||
|
else()
|
||||||
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# cmake setup end
|
||||||
|
|
||||||
|
add_subdirectory(./src)
|
||||||
|
|
||||||
|
if (SOLANACEAE_BRIDGE_BUILD_PLUGINS)
|
||||||
|
message("II SOLANACEAE_BRIDGE_BUILD_PLUGINS " ${SOLANACEAE_BRIDGE_BUILD_PLUGINS})
|
||||||
|
add_subdirectory(./plugins)
|
||||||
|
endif()
|
||||||
|
|
50
external/CMakeLists.txt
vendored
Normal file
50
external/CMakeLists.txt
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.14...3.24 FATAL_ERROR)
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
|
||||||
|
# TODO: move entt dep into solanaceae_contact
|
||||||
|
if (NOT TARGET EnTT::EnTT)
|
||||||
|
FetchContent_Declare(EnTT
|
||||||
|
GIT_REPOSITORY https://github.com/skypjack/entt.git
|
||||||
|
GIT_TAG v3.12.2
|
||||||
|
EXCLUDE_FROM_ALL
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(EnTT)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if (NOT TARGET solanaceae_util)
|
||||||
|
FetchContent_Declare(solanaceae_util
|
||||||
|
GIT_REPOSITORY https://github.com/Green-Sky/solanaceae_util.git
|
||||||
|
GIT_TAG master
|
||||||
|
EXCLUDE_FROM_ALL
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(solanaceae_util)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if (NOT TARGET solanaceae_contact)
|
||||||
|
FetchContent_Declare(solanaceae_contact
|
||||||
|
GIT_REPOSITORY https://github.com/Green-Sky/solanaceae_contact.git
|
||||||
|
GIT_TAG master
|
||||||
|
EXCLUDE_FROM_ALL
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(solanaceae_contact)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if (NOT TARGET solanaceae_message3)
|
||||||
|
FetchContent_Declare(solanaceae_message3
|
||||||
|
GIT_REPOSITORY https://github.com/Green-Sky/solanaceae_message3.git
|
||||||
|
GIT_TAG master
|
||||||
|
EXCLUDE_FROM_ALL
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(solanaceae_message3)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if (NOT TARGET solanaceae_plugin)
|
||||||
|
FetchContent_Declare(solanaceae_plugin
|
||||||
|
GIT_REPOSITORY https://github.com/Green-Sky/solanaceae_plugin.git
|
||||||
|
GIT_TAG master
|
||||||
|
EXCLUDE_FROM_ALL
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(solanaceae_plugin)
|
||||||
|
endif()
|
||||||
|
|
11
plugins/CMakeLists.txt
Normal file
11
plugins/CMakeLists.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.14...3.24 FATAL_ERROR)
|
||||||
|
|
||||||
|
add_library(plugin_bridge SHARED
|
||||||
|
./plugin_bridge.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(plugin_bridge PUBLIC
|
||||||
|
solanaceae_plugin
|
||||||
|
solanaceae_bridge
|
||||||
|
)
|
||||||
|
|
78
plugins/plugin_bridge.cpp
Normal file
78
plugins/plugin_bridge.cpp
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
#include <solanaceae/plugin/solana_plugin_v1.h>
|
||||||
|
|
||||||
|
#include "../src/bridge.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
#define RESOLVE_INSTANCE(x) static_cast<x*>(solana_api->resolveInstance(#x))
|
||||||
|
#define PROVIDE_INSTANCE(x, p, v) solana_api->provideInstance(#x, p, static_cast<x*>(v))
|
||||||
|
|
||||||
|
static std::unique_ptr<Bridge> g_bridge = nullptr;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
SOLANA_PLUGIN_EXPORT const char* solana_plugin_get_name(void) {
|
||||||
|
return "Bridge";
|
||||||
|
}
|
||||||
|
|
||||||
|
SOLANA_PLUGIN_EXPORT uint32_t solana_plugin_get_version(void) {
|
||||||
|
return SOLANA_PLUGIN_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
SOLANA_PLUGIN_EXPORT uint32_t solana_plugin_start(struct SolanaAPI* solana_api) {
|
||||||
|
std::cout << "PLUGIN Bridge START()\n";
|
||||||
|
|
||||||
|
if (solana_api == nullptr) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact3Registry* cr;
|
||||||
|
RegistryMessageModel* rmm = nullptr;
|
||||||
|
ConfigModelI* conf = nullptr;
|
||||||
|
|
||||||
|
{ // make sure required types are loaded
|
||||||
|
cr = RESOLVE_INSTANCE(Contact3Registry);
|
||||||
|
rmm = RESOLVE_INSTANCE(RegistryMessageModel);
|
||||||
|
conf = RESOLVE_INSTANCE(ConfigModelI);
|
||||||
|
|
||||||
|
if (cr == nullptr) {
|
||||||
|
std::cerr << "PLUGIN Bridge missing Contact3Registry\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rmm == nullptr) {
|
||||||
|
std::cerr << "PLUGIN Bridge missing RegistryMessageModel\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conf == nullptr) {
|
||||||
|
std::cerr << "PLUGIN Bridge missing ConfigModelI\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// static store, could be anywhere tho
|
||||||
|
// construct with fetched dependencies
|
||||||
|
g_bridge = std::make_unique<Bridge>(*cr, *rmm, *conf);
|
||||||
|
|
||||||
|
// register types
|
||||||
|
PROVIDE_INSTANCE(Bridge, "Bridge", g_bridge.get());
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
SOLANA_PLUGIN_EXPORT void solana_plugin_stop(void) {
|
||||||
|
std::cout << "PLUGIN Bridge STOP()\n";
|
||||||
|
|
||||||
|
g_bridge.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
SOLANA_PLUGIN_EXPORT void solana_plugin_tick(float delta) {
|
||||||
|
(void)delta;
|
||||||
|
//std::cout << "PLUGIN Bridge TICK()\n";
|
||||||
|
g_bridge->iterate(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern C
|
||||||
|
|
13
src/CMakeLists.txt
Normal file
13
src/CMakeLists.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
|
||||||
|
|
||||||
|
add_library(solanaceae_bridge STATIC
|
||||||
|
./bridge.hpp
|
||||||
|
./bridge.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_features(solanaceae_bridge PUBLIC cxx_std_17)
|
||||||
|
target_link_libraries(solanaceae_bridge PUBLIC
|
||||||
|
solanaceae_contact
|
||||||
|
solanaceae_message3
|
||||||
|
)
|
||||||
|
|
184
src/bridge.cpp
Normal file
184
src/bridge.cpp
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#include "./bridge.hpp"
|
||||||
|
|
||||||
|
#include <solanaceae/util/config_model.hpp>
|
||||||
|
#include <solanaceae/contact/components.hpp>
|
||||||
|
#include <solanaceae/message3/components.hpp>
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
constexpr uint8_t nib_from_hex(char c) {
|
||||||
|
assert((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
||||||
|
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
return static_cast<uint8_t>(c) - '0';
|
||||||
|
} else if (c >= 'a' && c <= 'f') {
|
||||||
|
return (static_cast<uint8_t>(c) - 'a') + 10u;
|
||||||
|
} else {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr char nib_to_hex(uint8_t c) {
|
||||||
|
assert(c <= 0x0f);
|
||||||
|
|
||||||
|
if (c <= 0x09) {
|
||||||
|
return c + '0';
|
||||||
|
} else {
|
||||||
|
return (c - 10u) + 'a';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // detail
|
||||||
|
|
||||||
|
static std::vector<uint8_t> hex2bin(const std::string_view str) {
|
||||||
|
assert(str.size() % 2 == 0);
|
||||||
|
std::vector<uint8_t> bin{};
|
||||||
|
bin.resize(str.size()/2, 0);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < bin.size(); i++) {
|
||||||
|
bin[i] = detail::nib_from_hex(str[i*2]) << 4 | detail::nib_from_hex(str[i*2+1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string bin2hex(const std::vector<uint8_t> data) {
|
||||||
|
std::string str;
|
||||||
|
for (size_t i = 0; i < data.size(); i++) {
|
||||||
|
str.push_back(detail::nib_to_hex(data[i] >> 4));
|
||||||
|
str.push_back(detail::nib_to_hex(data[i] & 0x0f));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bridge::Bridge(
|
||||||
|
Contact3Registry& cr,
|
||||||
|
RegistryMessageModel& rmm,
|
||||||
|
ConfigModelI& conf
|
||||||
|
) : _cr(cr), _rmm(rmm), _conf(conf) {
|
||||||
|
_rmm.subscribe(this, enumType::message_construct);
|
||||||
|
|
||||||
|
// load synced contacts (bridged groups)
|
||||||
|
std::map<std::string, size_t> tmp_name_to_id;
|
||||||
|
for (const auto [contact_id, vgroup_str] : _conf.entries_string("Bridge", "contact_to_vgroup")) {
|
||||||
|
const auto tmp_vgroup_str = std::string{vgroup_str.start, vgroup_str.start+vgroup_str.extend};
|
||||||
|
if (!tmp_name_to_id.count(tmp_vgroup_str)) {
|
||||||
|
tmp_name_to_id[tmp_vgroup_str] = _vgroups.size();
|
||||||
|
_vgroups.emplace_back();;
|
||||||
|
}
|
||||||
|
auto& v_group = _vgroups.at(tmp_name_to_id.at(tmp_vgroup_str));
|
||||||
|
|
||||||
|
auto& new_vgc = v_group.contacts.emplace_back();
|
||||||
|
new_vgc.c = {_cr, entt::null}; // this is annoying af
|
||||||
|
new_vgc.id = hex2bin(contact_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
Bridge::~Bridge(void) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void Bridge::iterate(float time_delta) {
|
||||||
|
_iterate_timer += time_delta;
|
||||||
|
if (_iterate_timer >= 10.f) {
|
||||||
|
_iterate_timer = 0.f;
|
||||||
|
|
||||||
|
updateVGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Bridge::updateVGroups(void) {
|
||||||
|
// fill in contacts, some contacts might be created late
|
||||||
|
for (size_t i_vg = 0; i_vg < _vgroups.size(); i_vg++) {
|
||||||
|
for (auto& vgc : _vgroups[i_vg].contacts) {
|
||||||
|
assert(!vgc.id.empty());
|
||||||
|
|
||||||
|
if (!vgc.c.valid()) {
|
||||||
|
// search
|
||||||
|
auto view = _cr.view<Contact::Components::TagBig, Contact::Components::ID>();
|
||||||
|
for (const auto c : view) {
|
||||||
|
if (view.get<Contact::Components::ID>(c).data == vgc.id) {
|
||||||
|
vgc.c = {_cr, c};
|
||||||
|
std::cout << "Bridge: found contact for vgroup\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vgc.c.valid()) {
|
||||||
|
_c_to_vg[vgc.c] = i_vg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Bridge::onEvent(const Message::Events::MessageConstruct& e) {
|
||||||
|
if (!e.e.valid()) {
|
||||||
|
return false; // how
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.e.all_of<Message::Components::MessageText>()) {
|
||||||
|
return false; // non text message, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.e.all_of<Message::Components::ContactFrom, Message::Components::ContactTo>()) {
|
||||||
|
return false; // how
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto contact_from = e.e.get<Message::Components::ContactFrom>().c;
|
||||||
|
if (_cr.any_of<Contact::Components::TagSelfStrong, Contact::Components::TagSelfWeak>(contact_from)) {
|
||||||
|
return false; // skip own messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto contact_to = e.e.get<Message::Components::ContactTo>().c;
|
||||||
|
// if e.e <contact to> is in c to vg
|
||||||
|
const auto it = _c_to_vg.find(Contact3Handle{_cr, contact_to});
|
||||||
|
if (it == _c_to_vg.cend()) {
|
||||||
|
return false; // contact is not bridged
|
||||||
|
}
|
||||||
|
const auto& vgroup = _vgroups.at(it->second);
|
||||||
|
const auto& message_text = e.e.get<Message::Components::MessageText>().text;
|
||||||
|
const bool is_action = e.e.all_of<Message::Components::TagMessageIsAction>();
|
||||||
|
|
||||||
|
std::string from_str;
|
||||||
|
if (_cr.all_of<Contact::Components::Name>(contact_from)) {
|
||||||
|
const auto& name = _cr.get<Contact::Components::Name>(contact_from).name;
|
||||||
|
if (name.empty()) {
|
||||||
|
from_str += "<UNK";
|
||||||
|
} else {
|
||||||
|
from_str += "<";
|
||||||
|
from_str += name.substr(0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_cr.all_of<Contact::Components::ID>(contact_from)) {
|
||||||
|
// copy
|
||||||
|
auto id = _cr.get<Contact::Components::ID>(contact_from).data;
|
||||||
|
id.resize(3);
|
||||||
|
|
||||||
|
from_str += "#" + bin2hex(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
from_str += "> ";
|
||||||
|
|
||||||
|
// for each c in vg not c...
|
||||||
|
for (const auto& other_vc : vgroup.contacts) {
|
||||||
|
if (other_vc.c == contact_to) {
|
||||||
|
continue; // skip self
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: support fake/virtual contacts. true bridging
|
||||||
|
std::string relayed_message {from_str};
|
||||||
|
|
||||||
|
relayed_message += message_text;
|
||||||
|
|
||||||
|
_rmm.sendText(
|
||||||
|
other_vc.c,
|
||||||
|
relayed_message,
|
||||||
|
is_action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
48
src/bridge.hpp
Normal file
48
src/bridge.hpp
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <solanaceae/message3/registry_message_model.hpp>
|
||||||
|
#include <solanaceae/contact/contact_model3.hpp>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
// fwd
|
||||||
|
struct ConfigModelI;
|
||||||
|
|
||||||
|
class Bridge : public RegistryMessageModelEventI {
|
||||||
|
Contact3Registry& _cr;
|
||||||
|
RegistryMessageModel& _rmm;
|
||||||
|
ConfigModelI& _conf;
|
||||||
|
|
||||||
|
struct VirtualGroups {
|
||||||
|
struct VContact {
|
||||||
|
Contact3Handle c; // might be null
|
||||||
|
std::vector<uint8_t> id; // if contact appears, we check
|
||||||
|
};
|
||||||
|
std::vector<VContact> contacts;
|
||||||
|
|
||||||
|
// metadata/settings?
|
||||||
|
};
|
||||||
|
std::vector<VirtualGroups> _vgroups;
|
||||||
|
std::map<Contact3Handle, size_t> _c_to_vg;
|
||||||
|
|
||||||
|
float _iterate_timer {0.f};
|
||||||
|
|
||||||
|
public:
|
||||||
|
Bridge(
|
||||||
|
Contact3Registry& cr,
|
||||||
|
RegistryMessageModel& rmm,
|
||||||
|
ConfigModelI& conf
|
||||||
|
);
|
||||||
|
~Bridge(void);
|
||||||
|
|
||||||
|
void iterate(float time_delta);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateVGroups(void);
|
||||||
|
|
||||||
|
protected: // mm
|
||||||
|
bool onEvent(const Message::Events::MessageConstruct& e) override;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue
Block a user