Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85803821
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
93 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/client/CMakeLists.txt b/src/client/CMakeLists.txt
index 0d74183..5db284e 100644
--- a/src/client/CMakeLists.txt
+++ b/src/client/CMakeLists.txt
@@ -1,50 +1,52 @@
set(kazvclient_SRCS
sdk-model.cpp
client-model.cpp
client.cpp
clientutil.cpp
status-utils.cpp
actions/auth.cpp
actions/sync.cpp
actions/paginate.cpp
actions/membership.cpp
actions/states.cpp
actions/account-data.cpp
actions/send.cpp
actions/ephemeral.cpp
actions/content.cpp
actions/encryption.cpp
actions/profile.cpp
device-list-tracker.cpp
encrypted-file.cpp
room/room-model.cpp
room/room.cpp
push-rules-desc.cpp
notification-handler.cpp
power-levels-desc.cpp
get-content-job-v1.cpp
+ alias.cpp
+ encode.cpp
)
add_library(kazvclient ${kazvclient_SRCS})
add_library(libkazv::kazvclient ALIAS kazvclient)
set_target_properties(kazvclient PROPERTIES VERSION ${libkazv_VERSION_STRING} SOVERSION ${libkazv_SOVERSION})
# https://github.com/google/googletest/issues/1841
if (MINGW)
target_compile_options(kazvclient PRIVATE "-Wa,-mbig-obj")
endif()
target_link_libraries(kazvclient PUBLIC kazvbase kazvapi kazvcrypto kazvstore Boost::regex)
target_include_directories(kazvclient PRIVATE .)
target_include_directories(kazvclient
INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:include/kazv/client>
)
install(TARGETS kazvclient EXPORT libkazvTargets LIBRARY)
diff --git a/src/client/actions/membership.cpp b/src/client/actions/membership.cpp
index 3ecc229..46971d7 100644
--- a/src/client/actions/membership.cpp
+++ b/src/client/actions/membership.cpp
@@ -1,293 +1,297 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <csapi/create_room.hpp>
#include <csapi/inviting.hpp>
#include <csapi/joining.hpp>
#include <debug.hpp>
#include "client-model.hpp"
#include "clientutil.hpp"
#include "cursorutil.hpp"
#include "status-utils.hpp"
#include "membership.hpp"
namespace Kazv
{
static std::string visibilityToStr(CreateRoomAction::Visibility v)
{
using V = CreateRoomAction::Visibility;
switch (v) {
case V::Private:
return "private";
case V::Public:
return "public";
default:
// should not happen
return "";
}
}
static std::string presetToStr(CreateRoomAction::Preset p)
{
using P = CreateRoomAction::Preset;
switch (p) {
case P::PrivateChat:
return "private_chat";
case P::PublicChat:
return "public_chat";
case P::TrustedPrivateChat:
return "trusted_private_chat";
default:
// should not happen
return "";
}
}
ClientResult updateClient(ClientModel m, CreateRoomAction a)
{
auto visibility = visibilityToStr(a.visibility);
std::optional<std::string> preset = a.preset
? std::optional<std::string>(presetToStr(a.preset.value()))
: std::nullopt;
using StateEvT = Kazv::CreateRoomJob::StateEvent;
auto initialState = intoImmer(
immer::array<StateEvT>{},
zug::map(
[](Event e) {
return StateEvT{e.type(), e.content(), e.stateKey()};
}),
a.initialState);
auto job = m.job<CreateRoomJob>().make(
visibility,
a.roomAliasName,
a.name,
a.topic,
a.invite,
DEFVAL, // invite3pid, not supported yet
a.roomVersion,
a.creationContent,
initialState,
preset,
a.isDirect,
a.powerLevelContentOverride);
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, CreateRoomResponse r)
{
if (! r.success()) {
kzo.client.dbg() << "Create room failed" << std::endl;
m.addTrigger(CreateRoomFailed{r.errorCode(), r.errorMessage()});
return { std::move(m), failWithResponse(r) };
}
m.addTrigger(CreateRoomSuccessful{r.roomId()});
- return { std::move(m), lager::noop };
+ return { std::move(m), [=](auto &&) {
+ return EffectStatus(/* succ = */ true, json{
+ {"roomId", r.roomId()},
+ });
+ }};
}
ClientResult updateClient(ClientModel m, InviteToRoomAction a)
{
auto job = m.job<InviteUserJob>()
.make(a.roomId, a.userId)
.withData(json{
{"roomId", a.roomId},
{"userId", a.userId},
});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, InviteUserResponse r)
{
auto roomId = r.dataStr("roomId");
auto userId = r.dataStr("userId");
if (! r.success()) {
// Error
kzo.client.warn() << "Error inviting user " << r.errorCode() << r.errorMessage() << std::endl;
m.addTrigger(InviteUserFailed{roomId, userId, r.errorCode(), r.errorMessage()});
return { std::move(m), failWithResponse(r) };
}
kzo.client.info() << "Inviting user successful" << std::endl;
m.addTrigger(InviteUserSuccessful{roomId, userId});
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, JoinRoomAction a)
{
/// HACK: wait for real boost::url
auto encodedId = a.roomIdOrAlias;
if (!encodedId.empty() && encodedId[0] == '#') {
encodedId.replace(0, 1, "%23");
}
auto job = m.job<JoinRoomJob>()
.make(encodedId, a.serverName)
.withData(json{{"roomIdOrAlias", a.roomIdOrAlias}});
m.addJob(std::move(job));
return { m, lager::noop };
}
ClientResult processResponse(ClientModel m, JoinRoomResponse r)
{
auto roomIdOrAlias = r.dataStr("roomIdOrAlias");
if (! r.success()) {
m.addTrigger(JoinRoomFailed{
roomIdOrAlias,
r.errorCode(),
r.errorMessage()
});
kzo.client.warn() << "Error joining room" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.info() << "Successfully joined room" << std::endl;
m.addTrigger(JoinRoomSuccessful{roomIdOrAlias});
return { std::move(m), lager::noop};
}
ClientResult updateClient(ClientModel m, JoinRoomByIdAction a)
{
auto job = m.job<JoinRoomByIdJob>()
.make(a.roomId)
.withData(json{{"roomIdOrAlias", a.roomId}});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, JoinRoomByIdResponse r)
{
auto roomIdOrAlias = r.dataStr("roomIdOrAlias");
if (! r.success()) {
m.addTrigger(JoinRoomFailed{
roomIdOrAlias,
r.errorCode(),
r.errorMessage()
});
kzo.client.warn() << "Error joining room" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.info() << "Successfully joined room" << std::endl;
m.addTrigger(JoinRoomSuccessful{roomIdOrAlias});
return { std::move(m), lager::noop};
}
ClientResult updateClient(ClientModel m, LeaveRoomAction a)
{
auto job = m.job<LeaveRoomJob>()
.make(a.roomId)
.withData(json{{"roomId", a.roomId}});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, LeaveRoomResponse r)
{
auto roomId = r.dataStr("roomId");
if (! r.success()) {
m.addTrigger(LeaveRoomFailed{
roomId,
r.errorCode(),
r.errorMessage()
});
kzo.client.warn() << "Error leaving room" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.info() << "Successfully left room" << std::endl;
m.addTrigger(LeaveRoomSuccessful{roomId});
return { std::move(m), lager::noop};
}
ClientResult updateClient(ClientModel m, ForgetRoomAction a)
{
auto job = m.job<ForgetRoomJob>()
.make(a.roomId)
.withData(json{{"roomId", a.roomId}});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, ForgetRoomResponse r)
{
auto roomId = r.dataStr("roomId");
if (! r.success()) {
m.addTrigger(ForgetRoomFailed{
roomId,
r.errorCode(),
r.errorMessage()
});
kzo.client.warn() << "Error forgetting room" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.info() << "Successfully forgot room" << std::endl;
m.addTrigger(ForgetRoomSuccessful{roomId});
return { std::move(m), lager::noop};
}
ClientResult updateClient(ClientModel m, KickAction a)
{
m.addJob(m.job<KickJob>().make(a.roomId, a.userId, a.reason));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, KickResponse r)
{
if (!r.success()) {
kzo.client.warn() << "Error kicking" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, BanAction a)
{
m.addJob(m.job<BanJob>().make(a.roomId, a.userId, a.reason));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, BanResponse r)
{
if (!r.success()) {
kzo.client.warn() << "Error banning" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, UnbanAction a)
{
m.addJob(m.job<UnbanJob>().make(a.roomId, a.userId));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, UnbanResponse r)
{
if (!r.success()) {
kzo.client.warn() << "Error unbanning" << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
return { std::move(m), lager::noop };
}
};
diff --git a/src/client/alias.cpp b/src/client/alias.cpp
new file mode 100644
index 0000000..e7335d1
--- /dev/null
+++ b/src/client/alias.cpp
@@ -0,0 +1,42 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <libkazv-config.hpp>
+
+#include "alias.hpp"
+#include "status-utils.hpp"
+#include "encode.hpp"
+
+#include <csapi/directory.hpp>
+#include <context.hpp>
+
+namespace Kazv
+{
+ BaseJob getRoomIdByAliasJob(ClientModel m, std::string roomAlias)
+ {
+ roomAlias = percentEncode(roomAlias);
+ return m.job<GetRoomIdByAliasJob>().make(roomAlias);
+ }
+
+ EffectStatus parseGetRoomIdByAliasResponse(GetRoomIdByAliasResponse r)
+ {
+ if (!r.success()) {
+ return failWithResponse(r).effectStatus();
+ }
+
+ auto servers = json::array({});
+ for (auto s : r.servers()) {
+ servers.push_back(s);
+ }
+
+ return EffectStatus{
+ /* succ = */ true,
+ json{
+ {"roomId", r.roomId().value()},
+ {"servers", servers}}
+ };
+ }
+}
diff --git a/src/client/alias.hpp b/src/client/alias.hpp
new file mode 100644
index 0000000..6a27386
--- /dev/null
+++ b/src/client/alias.hpp
@@ -0,0 +1,21 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <libkazv-config.hpp>
+
+#include "client-model.hpp"
+
+#include <basejob.hpp>
+#include <csapi/directory.hpp>
+
+#include <string>
+
+namespace Kazv
+{
+ BaseJob getRoomIdByAliasJob(ClientModel m, std::string roomAlias);
+ EffectStatus parseGetRoomIdByAliasResponse(GetRoomIdByAliasResponse r);
+}
diff --git a/src/client/client.cpp b/src/client/client.cpp
index 29d5757..c894ce2 100644
--- a/src/client/client.cpp
+++ b/src/client/client.cpp
@@ -1,470 +1,503 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <filesystem>
+#include <algorithm>
#include <lager/constant.hpp>
#include "client.hpp"
+#include "client-model.hpp"
+#include "alias.hpp"
namespace Kazv
{
Client::Client(lager::reader<SdkModel> sdk,
ContextT ctx, std::nullopt_t)
: m_sdk(sdk)
, m_client(sdk.map(&SdkModel::c))
, m_ctx(std::move(ctx))
{
}
Client::Client(lager::reader<SdkModel> sdk,
ContextWithDepsT ctx)
: m_sdk(sdk)
, m_client(sdk.map(&SdkModel::c))
, m_ctx(ctx)
, m_deps(std::move(ctx))
{
}
Client::Client(InEventLoopTag,
ContextWithDepsT ctx)
: m_sdk(std::nullopt)
, m_client(std::nullopt)
, m_ctx(ctx)
, m_deps(std::move(ctx))
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, KAZV_ON_EVENT_LOOP_VAR(true)
#endif
{
}
Client::Client(InEventLoopTag,
ContextT ctx, DepsT deps)
: m_sdk(std::nullopt)
, m_client(std::nullopt)
, m_ctx(std::move(ctx))
, m_deps(std::move(deps))
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, KAZV_ON_EVENT_LOOP_VAR(true)
#endif
{
}
Client Client::toEventLoop() const
{
return Client(InEventLoopTag{}, m_ctx, m_deps.value());
}
Room Client::room(std::string id) const
{
if (m_deps.has_value()) {
return Room(sdkCursor(), lager::make_constant(id), m_ctx, m_deps.value());
} else {
return Room(sdkCursor(), lager::make_constant(id), m_ctx);
}
}
Room Client::roomByCursor(lager::reader<std::string> id) const
{
if (m_deps.has_value()) {
return Room(sdkCursor(), id, m_ctx, m_deps.value());
} else {
return Room(sdkCursor(), id, m_ctx);
}
}
auto Client::passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName) const
-> PromiseT
{
auto p1 = m_ctx.dispatch(LoginAction{
homeserver, username, password, deviceName});
p1
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
// It is meaningless to wait for it in a Promise
// that is never exposed to the user.
that.startSyncing();
});
return p1;
}
auto Client::tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId) const
-> PromiseT
{
auto p1 = m_ctx.dispatch(TokenLoginAction{
homeserver, username, token, deviceId});
p1
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
that.startSyncing();
});
return p1;
}
auto Client::shouldSync() const -> lager::reader<bool> {
return this->clientCursor()[&ClientModel::shouldSync];
}
auto Client::logout() const
-> PromiseT
{
return stopSyncing().then([ctx=m_ctx] (auto stat) {
return ctx.dispatch(HardLogoutAction{});
});
}
auto Client::autoDiscover(std::string userId) const
-> PromiseT
{
return m_ctx.dispatch(GetWellknownAction{userId})
.then([that=toEventLoop()](auto stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
return that.m_ctx.dispatch(GetVersionsAction{stat.dataStr("homeserverUrl")})
.then([that, stat](auto stat2) {
if (!stat2.success()) {
return stat2;
} else {
return stat;
}
});
});
}
auto Client::createRoom(
RoomVisibility v,
std::optional<std::string> name,
std::optional<std::string> alias,
immer::array<std::string> invite,
std::optional<bool> isDirect,
bool allowFederate,
std::optional<std::string> topic,
JsonWrap powerLevelContentOverride,
std::optional<CreateRoomPreset> preset,
immer::array<Event> initialState
) const
-> PromiseT
{
CreateRoomAction a;
a.visibility = v;
a.name = name;
a.roomAliasName = alias;
a.invite = invite;
a.isDirect = isDirect;
a.topic = topic;
a.powerLevelContentOverride = powerLevelContentOverride;
// Synapse won't buy it if we do not provide
// a creationContent object.
a.creationContent = json{
{"m.federate", allowFederate}
};
a.preset = preset;
a.initialState = initialState;
return m_ctx.dispatch(std::move(a));
}
auto Client::joinRoomById(std::string roomId) const -> PromiseT
{
return m_ctx.dispatch(JoinRoomByIdAction{roomId});
}
auto Client::joinRoom(std::string roomId, immer::array<std::string> serverName) const
-> PromiseT
{
return m_ctx.dispatch(JoinRoomAction{roomId, serverName});
}
auto Client::uploadContent(immer::box<Bytes> content,
std::string uploadId,
std::optional<std::string> filename,
std::optional<std::string> contentType) const
-> PromiseT
{
return m_ctx.dispatch(UploadContentAction{
FileDesc(FileContent{content.get().begin(), content.get().end()}),
filename, contentType, uploadId});
}
auto Client::uploadContent(FileDesc file) const
-> PromiseT
{
auto basename = file.name()
? std::optional(std::filesystem::path(file.name().value()).filename().string())
: std::nullopt;
return m_ctx.dispatch(UploadContentAction{
file,
// use only basename to prevent path info being leaked
basename,
file.contentType(),
// uploadId unused
std::string{}});
}
std::string Client::mxcUriToHttpV1(std::string mxcUri) const {
using namespace CursorOp;
auto [serverName, mediaId] = mxcUriToMediaDesc(mxcUri);
return (+clientCursor())
.template job<GetContentJobV1>()
.make(serverName, mediaId).url();
}
auto Client::downloadContent(std::string mxcUri, std::optional<FileDesc> downloadTo) const
-> PromiseT
{
return m_ctx.dispatch(DownloadContentAction{mxcUri, downloadTo});
}
auto Client::downloadThumbnail(
std::string mxcUri,
int width,
int height,
std::optional<ThumbnailResizingMethod> method,
std::optional<FileDesc> downloadTo) const
-> PromiseT
{
return m_ctx.dispatch(DownloadThumbnailAction{mxcUri, width, height, method, std::nullopt, downloadTo});
}
auto Client::startSyncing() const -> PromiseT
{
KAZV_VERIFY_THREAD_ID();
using namespace Kazv::CursorOp;
if (+syncing()) {
return m_ctx.createResolvedPromise(true);
}
auto p1 = m_ctx.createResolvedPromise(true)
.then([that=toEventLoop()](auto) {
// post filters, if filters are incomplete
if ((+that.clientCursor()[&ClientModel::initialSyncFilterId]).empty()
|| (+that.clientCursor()[&ClientModel::incrementalSyncFilterId]).empty()) {
return that.m_ctx.dispatch(PostInitialFiltersAction{});
}
return that.m_ctx.createResolvedPromise(true);
})
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
// Upload identity keys if we need to
if (+that.clientCursor()[&ClientModel::crypto]
&& ! +that.clientCursor()[&ClientModel::identityKeysUploaded]) {
return that.m_ctx.dispatch(UploadIdentityKeysAction{});
} else {
return that.m_ctx.createResolvedPromise(true);
}
});
p1
.then([m_ctx=m_ctx](auto stat) {
m_ctx.dispatch(SetShouldSyncAction{true});
return stat;
})
.then([that=toEventLoop()](auto stat) {
if (stat.success()) {
that.syncForever();
}
});
return p1;
}
auto Client::syncForever(std::optional<int> retryTime) const -> void
{
KAZV_VERIFY_THREAD_ID();
// assert (m_deps);
using namespace CursorOp;
bool isInitialSync = ! (+clientCursor()[&ClientModel::syncToken]).has_value();
bool shouldSync = +clientCursor()[&ClientModel::shouldSync];
if (! shouldSync) {
return;
}
//
auto syncRes = m_ctx.dispatch(SyncAction{});
auto uploadOneTimeKeysRes = syncRes
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
auto &rg = lager::get<RandomInterface &>(that.m_deps.value());
bool hasCrypto{+that.clientCursor()[&ClientModel::crypto]};
if (! hasCrypto) {
return that.m_ctx.createResolvedPromise(true);
}
auto numKeysToGenerate = (+that.clientCursor()).numOneTimeKeysNeeded();
return that.m_ctx.dispatch(GenerateAndUploadOneTimeKeysAction{
numKeysToGenerate,
rg.generateRange<RandomData>(GenerateAndUploadOneTimeKeysAction::randomSize(numKeysToGenerate))
});
});
auto queryKeysRes = syncRes
.then([that=toEventLoop(), isInitialSync](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
bool hasCrypto{+that.clientCursor()[&ClientModel::crypto]};
return hasCrypto
? that.m_ctx.dispatch(QueryKeysAction{isInitialSync})
: that.m_ctx.createResolvedPromise(true);
});
m_ctx.promiseInterface()
.all(std::vector<PromiseT>{uploadOneTimeKeysRes, queryKeysRes})
.then([that=toEventLoop(), retryTime](auto stat) {
if (stat.success()) {
that.syncForever(); // reset retry time
} else {
auto firstRetryTime = +that.clientCursor()[&ClientModel::firstRetryMs];
auto retryTimeFactor = +that.clientCursor()[&ClientModel::retryTimeFactor];
auto maxRetryTime = +that.clientCursor()[&ClientModel::maxRetryMs];
auto curRetryTime = retryTime ? retryTime.value() : firstRetryTime;
if (curRetryTime > maxRetryTime) { curRetryTime = maxRetryTime; }
auto nextRetryTime = curRetryTime * retryTimeFactor;
kzo.client.warn() << "Sync failed, retrying in " << curRetryTime << "ms" << std::endl;
auto &jh = getJobHandler(that.m_deps.value());
jh.setTimeout([that=that.toEventLoop(), nextRetryTime]() { that.syncForever(nextRetryTime); },
curRetryTime);
}
});
}
auto Client::stopSyncing() const -> PromiseT
{
return m_ctx.dispatch(SetShouldSyncAction{false});
}
lager::reader<ClientModel> Client::clientCursor() const
{
KAZV_VERIFY_THREAD_ID();
if (m_client.has_value()) {
return m_client.value();
} else {
assert(m_deps.has_value());
return lager::get<SdkModelCursorKey>(m_deps.value())->map(&SdkModel::c);
}
}
const lager::reader<SdkModel> &Client::sdkCursor() const
{
KAZV_VERIFY_THREAD_ID();
if (m_sdk.has_value()) {
return m_sdk.value();
} else {
assert(m_deps.has_value());
return *(lager::get<SdkModelCursorKey>(m_deps.value()));
}
}
auto Client::getProfile(std::string userId) const -> PromiseT
{
return m_ctx.dispatch(GetUserProfileAction{userId});
}
auto Client::setAvatarUrl(std::optional<std::string> avatarUrl) const -> PromiseT
{
return m_ctx.dispatch(SetAvatarUrlAction{avatarUrl});
}
auto Client::setDisplayName(std::optional<std::string> displayName) const -> PromiseT
{
return m_ctx.dispatch(SetDisplayNameAction{displayName});
}
auto Client::devicesOfUser(std::string userId) const -> lager::reader<immer::flex_vector<DeviceKeyInfo>>
{
return clientCursor()
[&ClientModel::deviceLists]
[&DeviceListTracker::deviceLists]
[userId]
[lager::lenses::or_default]
.xform(containerMap(immer::flex_vector<DeviceKeyInfo>{}, zug::map([](const auto &pair) {
const auto &[deviceId, info] = pair;
(void)deviceId;
return info;
})));
}
auto Client::setDeviceTrustLevel(std::string userId, std::string deviceId, DeviceTrustLevel trustLevel) const -> PromiseT
{
return m_ctx.dispatch(SetDeviceTrustLevelAction{userId, deviceId, trustLevel});
}
auto Client::trustLevelNeededToSendKeys() const -> lager::reader<DeviceTrustLevel>
{
return clientCursor()[&ClientModel::trustLevelNeededToSendKeys];
}
auto Client::setTrustLevelNeededToSendKeys(DeviceTrustLevel trustLevel) const -> PromiseT
{
return m_ctx.dispatch(SetTrustLevelNeededToSendKeysAction{trustLevel});
}
auto Client::directRoomMap() const -> lager::reader<immer::map<std::string, std::string>>
{
return clientCursor().map(&ClientModel::directRoomMap);
}
auto Client::roomIdsUnderTag(std::string tagId) const -> lager::reader<immer::map<std::string, double>>
{
return clientCursor().map([tagId](const auto &c) {
return c.roomIdsUnderTag(tagId);
});
}
auto Client::roomIdsByTagId() const -> lager::reader<immer::map<std::string, immer::map<std::string, double>>>
{
return clientCursor().map(&ClientModel::roomIdsByTagId);
}
auto Client::accountData() const -> lager::reader<immer::map<std::string, Event>>
{
return clientCursor()[&ClientModel::accountData];
}
auto Client::setAccountData(Event accountDataEvent) const -> PromiseT
{
return m_ctx.dispatch(SetAccountDataAction{accountDataEvent});
}
NotificationHandler Client::notificationHandler() const
{
return NotificationHandler(clientCursor());
}
auto Client::getVersions(std::string homeserver) const -> PromiseT
{
return m_ctx.dispatch(GetVersionsAction{homeserver});
}
auto Client::supportVersions() const -> lager::reader<immer::array<std::string>>
{
return clientCursor()[&ClientModel::versions];
}
+
+ auto Client::addDirectRoom(std::string userId, std::string roomId) const -> PromiseT
+ {
+ auto content = this->accountData().get()["m.direct"].content().get();
+
+ if (content.contains(userId)) {
+ auto& rooms = content[userId];
+ if (rooms.is_array()) {
+ if (std::find(rooms.begin(), rooms.end(), roomId) != rooms.end()) {
+ // The roomId is already in the m.direct, do nothing
+ return m_ctx.createResolvedPromise(true);
+ }
+ } else {
+ rooms = json::array({});
+ }
+ } else {
+ content.emplace(userId, json::array({}));
+ }
+
+ content[userId].push_back(roomId);
+ return Client::setAccountData(json{
+ {"type", "m.direct"},
+ {"content", std::move(content)}
+ });
+ }
+
+ auto Client::getRoomIdByAliasJob(std::string roomAlias) const -> BaseJob
+ {
+ return Kazv::getRoomIdByAliasJob(clientCursor().get(), roomAlias);
+ }
}
diff --git a/src/client/client.hpp b/src/client/client.hpp
index da01576..150a803 100644
--- a/src/client/client.hpp
+++ b/src/client/client.hpp
@@ -1,601 +1,620 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/reader.hpp>
#include <immer/box.hpp>
#include <immer/map.hpp>
#include <immer/flex_vector.hpp>
#include <immer/flex_vector_transient.hpp>
#include "sdk-model.hpp"
#include "client/client-model.hpp"
#include "client/actions/content.hpp"
#include "sdk-model-cursor-tag.hpp"
#include "get-content-job-v1.hpp"
#include "room/room.hpp"
#include "notification-handler.hpp"
namespace Kazv
{
/**
* Represent a Matrix client.
*
* If the Client is constructed from a cursor originated from
* a root whose event loop is on thread A, then we say that
* the Client belongs to thread A. If the Client is not constructed
* from a cursor, then we say that the Client belongs to the thread
* where the event loop of the context runs.
*
* All methods in this class that take a cursor only take a cursor
* on the same thread as the Client. All methods in this class that
* return a cursor will return a cursor on the same thread as the Client.
*
* All methods in this class must be run on the same thread as the
* the Client. If the Client is not constructed from a cursor,
* copy-constructing another Client from this is safe from any thread.
* If the Client is constructed from a cursor, copy-constructing another
* Client is safe only from the same thread as this Client.
*
* ## Error handling
*
* A lot of functions in Client and Room are asynchronous actions.
* These actions return the result via a Promise.
* If an API request has failed, the Promise p will satisfy the following:
* - `!p.success()`
* - `p.dataStr("error")` will contain the error message from the response.
* - `p.dataStr("errorCode")` will contain the matrix error code, if available,
* or the HTTP status code otherwise.
*
* What information is resolved if the API request has succeeded is defined
* by individual functions.
*/
class Client
{
public:
using ActionT = ClientAction;
using DepsT = lager::deps<JobInterface &, EventInterface &, SdkModelCursorKey, RandomInterface &
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, EventLoopThreadIdKeeper &
#endif
>;
using ContextT = Context<ActionT>;
using ContextWithDepsT = Context<ActionT, DepsT>;
using PromiseT = SingleTypePromise<DefaultRetType>;
struct InEventLoopTag {};
/**
* Constructor.
*
* Construct the client. Without Deps support.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(lager::reader<SdkModel> sdk,
ContextT ctx, std::nullopt_t);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* This enables startSyncing() to work properly.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(lager::reader<SdkModel> sdk,
ContextWithDepsT ctx);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* The constructed Client belongs to the thread of event loop.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(InEventLoopTag,
ContextWithDepsT ctx);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* The constructed Client belongs to the thread of event loop.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(InEventLoopTag, ContextT ctx, DepsT deps);
/**
* Create a Client that is not constructed from a cursor.
*
* The returned Client belongs to the thread of event loop.
*
* This function is thread-safe if every thread calls it
* using different objects.
*
* @return A Client not constructed from a cursor.
*/
Client toEventLoop() const;
/* lager::reader<immer::map<std::string, Room>> */
inline auto rooms() const {
return clientCursor()
[&ClientModel::roomList]
[&RoomListModel::rooms];
}
/* lager::reader<RangeT<std::string>> */
inline auto roomIds() const {
return rooms().xform(
zug::map([](auto m) {
return intoImmer(
immer::flex_vector<std::string>{},
zug::map([](auto val) { return val.first; }),
m);
}));
}
auto roomIdsUnderTag(std::string tagId) const -> lager::reader<immer::map<std::string, double>>;
/**
* Get the room ids under all tags.
*
* @return A lager::reader containing the map from tag id to a map from room id to order.
* Rooms without a tag will be under the tag id of the empty string.
*/
auto roomIdsByTagId() const -> lager::reader<immer::map<std::string, immer::map<std::string, double>>>;
KAZV_WRAP_ATTR(ClientModel, clientCursor(), serverUrl)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), loggedIn)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), userId)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), token)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), deviceId)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), toDevice)
/**
* Get the room with @c id .
*
* This is equivalent to `roomByCursor(lager::make_constant(id))`.
*
* @param id The room id.
* @return A Room representing the room with `id`.
*/
Room room(std::string id) const;
/**
* Get the room with `id`.
*
* The Room returned will change as the content in `id` changes.
*
* For example, you can have the Room that is always the first
* alphabetically in all rooms by:
*
* \code{.cpp}
* auto someProcessing =
* zug::map([=](auto ids) {
* std::sort(ids.begin(), ids.end(), [=](auto id1, auto id2) {
* using namespace Kazv::CursorOp;
* return (+client.room(id1).name()) < (+client.room(id2).name());
* });
* return ids;
* });
* auto room =
* client.roomByCursor(
* client.roomIds().xform(someProcessing)[0]);
* \endcode
*
* @param id A lager::reader<std::string> containing the room id.
* @return A Room representing the room with `id`.
*/
Room roomByCursor(lager::reader<std::string> id) const;
/**
* Login using the password.
*
* This will create a new session on the homeserver.
*
* If the returned Promise resolves successfully, this will
* call `startSyncing()`.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @param username The username. This can be the full user id or
* just the local part. E.g. `tusooa`, `@tusooa:tusooa.xyz`.
* @param password The password.
* @param deviceName Optionally, a custom device name. If empty, `libkazv`
* will be used.
* @return A Promise that resolves when logging in successfully, or
* when there is an error.
*/
PromiseT passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName) const;
/**
* Login using `token` and `deviceId`.
*
* This will not make a request. Library users should make sure
* the information is correct and the token and the device id are valid.
*
* If the returned Promise resolves successfully, this will
* call `startSyncing()`.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @param username The full user id. E.g. `@tusooa:tusooa.xyz`.
* @param token The access token.
* @param deviceId The device id that is paired with `token`.
* @return A Promise that resolves when the account information is filled in.
*/
PromiseT tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId) const;
/**
* Get the `shouldSync` field of current ClientModel.
*
* @return A lager::reader of bool of the `shouldSync` field of the ClientModel
*/
auto shouldSync() const -> lager::reader<bool>;
/**
* Stop syncing and then logout current session.
*
* Meanwhile, clear the current token and set loggedIn to false.
*
* @return A promise that resolves when the syncing is stopped.
*/
PromiseT logout() const;
/**
* Automatically discover the homeserver for `userId`.
*
* If the operation succeeds, `r.dataStr("homeserverUrl")` will contain
* the url suitable to pass to `tokenLogin()` and `passwordLogin()`.
*
* If there is no well-known file (i.e. server responds with 404),
* `r.dataStr("homeserverUrl")` will contain the domain part of the user
* id (`https://example.org` for `@foo:example.org`).
*
* @param userId The full user id. E.g. `@foo:example.org`.
* @return A Promise that resolves when the auto-discovery finishes.
*/
PromiseT autoDiscover(std::string userId) const;
/**
* Create a room.
*
* @param v The visibility of the room.
* @param name The name of the room.
* @param alias The alias of the room.
* @param invite User ids to invite to this room.
* @param isDirect Whether this room is a direct chat.
* @param allowFederate Whether to allow users from other homeservers
* to join this room.
* @param topic The topic of the room.
* @param powerLevelContentOverride The content of the m.room.power_levels
* state event to override the default.
* @param preset The preset to create the room with.
* @return A Promise that resolves when the room is created,
* or when there is an error.
*/
PromiseT createRoom(
RoomVisibility v,
std::optional<std::string> name = {},
std::optional<std::string> alias = {},
immer::array<std::string> invite = {},
std::optional<bool> isDirect = {},
bool allowFederate = true,
std::optional<std::string> topic = {},
JsonWrap powerLevelContentOverride = json::object(),
std::optional<CreateRoomPreset> preset = std::nullopt,
immer::array<Event> initialState = immer::array<Event>()
) const;
/**
* Join a room by its id.
*
* @param roomId The id of the room to join.
* @return A Promise that resolves when the room is joined,
* or when there is an error.
*/
PromiseT joinRoomById(std::string roomId) const;
/**
* Join a room by its id or alias.
*
* @param roomId The id *or alias* of the room to join.
* @param serverName A list of servers to use when joining the room.
* This corresponds to the `via` parameter in a matrix.to url.
* @return A Promise that resolves when the room is joined,
* or when there is an error.
*/
PromiseT joinRoom(std::string roomId, immer::array<std::string> serverName) const;
/**
* Upload content to the content repository.
*
* @param content The content to upload.
* @param uploadId
* @param filename The name of the file.
* @param contentType The content type of the file.
* @return A Promise that resolves when the upload is successful,
* or when there is an error. If it successfully resolves to `r`,
* `r.dataStr("mxcUri")` will be the MXC URI of the uploaded
* content.
*/
PromiseT uploadContent(immer::box<Bytes> content,
std::string uploadId,
std::optional<std::string> filename = std::nullopt,
std::optional<std::string> contentType = std::nullopt) const;
/**
* Upload content to the content repository.
*
* @param file The file to upload.
* @return A Promise that resolves when the upload is successful,
* or when there is an error. If it successfully resolves to `r`,
* `r.dataStr("mxcUri")` will be the MXC URI of the uploaded
* content.
*/
PromiseT uploadContent(FileDesc file) const;
/**
* Convert a MXC URI to an HTTP(s) URI.
*
* The converted URI will be using the homeserver of
* this Client.
*
* @param mxcUri The MXC URI to convert.
* @return The HTTP(s) URI that has the content indicated
* by `mxcUri`.
*/
inline std::string mxcUriToHttp(std::string mxcUri) const {
using namespace CursorOp;
auto [serverName, mediaId] = mxcUriToMediaDesc(mxcUri);
return (+clientCursor())
.template job<GetContentJob>()
.make(serverName, mediaId).url();
}
/**
* Convert a MXC URI to an HTTP(s) URI that needs Authorization.
*
* The converted URI will be using the homeserver of
* this Client.
*
* @param mxcUri The MXC URI to convert.
* @return The HTTP(s) URI that has the content indicated
* by `mxcUri`.
*/
std::string mxcUriToHttpV1(std::string mxcUri) const;
/**
* Download content from the content repository
*
* After the returned Promise resolves successfully,
* if @c downloadTo is provided, the content will be available
* in that file; if it is not provided, `r.dataStr("content")`
* will contain the content of the downloaded file.
*
* @param mxcUri The MXC URI of the content.
* @param downloadTo The file to write the content to. Must not be
* an in-memory file.
* @return A Promise that is resolved after the content
* is downloaded, or when there is an error.
*/
PromiseT downloadContent(std::string mxcUri,
std::optional<FileDesc> downloadTo = std::nullopt) const;
/**
* Download a thumbnail from the content repository
*
* After the returned Promise resolves successfully,
* if @c downloadTo is provided, the content will be available
* in that file; if it is not provided, `r.dataStr("content")`
* will contain the content of the downloaded file.
*
* @param mxcUri The MXC URI of the content.
* @param width,height The dimension wanted for the thumbnail
* @param method The method to generate the thumbnail. Either `Crop`
* or `Scale`.
* @param downloadTo The file to write the content to. Must not be
* an in-memory file.
* @return A Promise that is resolved after the thumbnail
* is downloaded, or when there is an error.
*/
PromiseT downloadThumbnail(std::string mxcUri,
int width,
int height,
std::optional<ThumbnailResizingMethod> method = std::nullopt,
std::optional<FileDesc> downloadTo = std::nullopt) const;
/**
* Fetch the profile of a user.
*
* @param userId The id of the user to fetch.
* @return A Promise that resolves when the fetch is completed.
* If successful, `r.dataStr("avatarUrl")` will contain the
* avatar url of that user, and `r.dataStr("displayName")` will
* contain the display name of that user.
*/
PromiseT getProfile(std::string userId) const;
/**
* Change the avatar url of the current user.
*
* @param avatarUrl The url of the new avatar. Should be an MXC URI.
* If it is std::nullopt, remove the user avatar.
* @return A Promise that resolves when the request is completed.
*/
PromiseT setAvatarUrl(std::optional<std::string> avatarUrl) const;
/**
* Change the display name of the current user.
*
* @param displayName The new display name. If it is std::nullopt,
* remove the user avatar.
* @return A Promise that resolves when the request is completed.
*/
PromiseT setDisplayName(std::optional<std::string> displayName) const;
// lager::reader<bool>
inline auto syncing() const {
return clientCursor()[&ClientModel::syncing];
}
/**
* Start syncing if the Client is not syncing.
*
* Syncing will continue indefinitely, if the preparation of
* the sync (posting filters and uploading identity keys,
* if needed) is successful, or until stopSyncing() is called.
*
* @return A Promise that resolves when the Client is syncing
* (more exactly, when syncing() contains true), or when there
* is an error in the preparation of the sync.
*/
PromiseT startSyncing() const;
/**
* Stop the indefinite syncing.
*
* After this, no more syncing actions will be dispatched.
*
* @return A Promise that resolves when syncing is stopped.
*/
PromiseT stopSyncing() const;
/**
* Get the info of all devices of user `userId` that supports encryption.
*
* @param userId The id of the user to get the devices of.
*
* @return a lager::reader of a RangeT of DeviceKeyInfo representing the devices of that user.
*/
auto devicesOfUser(std::string userId) const -> lager::reader<immer::flex_vector<DeviceKeyInfo>>;
/**
* Set the trust level of a device.
*
* @param userId The id of the user to whom the device belongs.
* @param deviceId The id of the device.
*
* @return a Promise that resolves when the setting is changed.
*/
PromiseT setDeviceTrustLevel(std::string userId, std::string deviceId, DeviceTrustLevel trustLevel) const;
/**
* Get the trust level needed to send keys to a device.
*
* @return a lager::reader of the trust level threshold.
*/
auto trustLevelNeededToSendKeys() const -> lager::reader<DeviceTrustLevel>;
/**
* Set the trust level needed to send keys to a device.
*
* @param trustLevel The trust level threshold.
*
* @return a Promise that resolves when the setting is changed.
*/
PromiseT setTrustLevelNeededToSendKeys(DeviceTrustLevel trustLevel) const;
/**
* Get the map from direct messaging room ids to user ids.
*
* @return a lager::reader of such mapping.
*/
auto directRoomMap() const -> lager::reader<immer::map<std::string, std::string>>;
/**
* Get the account data that is not associated with any room.
*
* @return A lager::reader of a map from the type to the account data event.
*/
auto accountData() const -> lager::reader<immer::map<std::string, Event>>;
/**
* Set the account data that is not associated with any room.
*
* @return A Promise that resolves when the account data
* has been set, or when there is an error.
*/
PromiseT setAccountData(Event accountDataEvent) const;
/**
* Get a notification handler that works on this Client.
*
* @return A notification handler that works on this Client.
*/
NotificationHandler notificationHandler() const;
/**
* Serialize the model to a Boost.Serialization archive.
*
* @param ar A Boost.Serialization output archive.
*
* This function can be used to save the model. For loading,
* you should use the makeSdk function. For example:
*
* ```c++
* client.serializeTo(outputAr);
*
* SdkModel m;
* inputAr >> m;
* auto newSdk = makeSdk(m, ...);
* ```
*/
template<class Archive>
void serializeTo(Archive &ar) const {
ar << sdkCursor().get();
}
/**
* Get all supported versions.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @return A Promise that resolves when the versions has been set,
* or when there is an error.
*/
PromiseT getVersions(std::string homeserver) const;
/**
* Get all supported versions.
*
* @return A lager::reader of a array contains all supported versions.
*
* See https://spec.matrix.org/v1.14/#specification-versions
*/
auto supportVersions() const -> lager::reader<immer::array<std::string>>;
+ /**
+ * Mark a room as a direct chat by send the m.direct account data.
+ *
+ * @param userId The user id that direct to.
+ * @param roomId The direct chat room id.
+ * @return A Promise that resolves when the account data
+ * has been set, or when there is an error.
+ */
+ PromiseT addDirectRoom(std::string userId, std::string roomId) const;
+
+ /**
+ * Get a GetRoomIdByAliasJob.
+ * Use Kazv::parseGetRoomIdByAliasResponse to parse its response.
+ *
+ * @param roomAlias The room alias.
+ * @return A GetRoomIdByAliasJob.
+ */
+ BaseJob getRoomIdByAliasJob(std::string roomAlias) const;
+
private:
void syncForever(std::optional<int> retryTime = std::nullopt) const;
const lager::reader<SdkModel> &sdkCursor() const;
lager::reader<ClientModel> clientCursor() const;
std::optional<lager::reader<SdkModel>> m_sdk;
std::optional<lager::reader<ClientModel>> m_client;
ContextT m_ctx;
std::optional<DepsT> m_deps;
KAZV_DECLARE_THREAD_ID();
KAZV_DECLARE_EVENT_LOOP_THREAD_ID_KEEPER(m_deps.has_value() ? &lager::get<EventLoopThreadIdKeeper &>(m_deps.value()) : 0);
};
}
diff --git a/src/client/encode.cpp b/src/client/encode.cpp
new file mode 100644
index 0000000..4379dea
--- /dev/null
+++ b/src/client/encode.cpp
@@ -0,0 +1,40 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <libkazv-config.hpp>
+
+#include "encode.hpp"
+
+#include <array>
+#include <algorithm>
+#include <sstream>
+#include <iostream>
+
+namespace Kazv
+{
+ // https://www.rfc-editor.org/rfc/rfc3986#section-2.2
+ static const std::array<char, 18> reservedChars{
+ '!', '#', '$', '&', '\'', '(', ')', '*', '+',
+ ',', '/', ':', ';', '=', '?', '@', '[', ']'};
+ static auto isReservedChar = [](char c) -> bool {
+ return std::find(reservedChars.begin(), reservedChars.end(), c)
+ != reservedChars.end();
+ };
+
+ std::string percentEncode(std::string url)
+ {
+ std::ostringstream result;
+ result << std::hex;
+ for (char c : url) {
+ if (isReservedChar(c)) {
+ result << '%' << static_cast<unsigned int>(c);
+ } else {
+ result << c;
+ }
+ }
+ return result.str();
+ }
+}
diff --git a/src/client/encode.hpp b/src/client/encode.hpp
new file mode 100644
index 0000000..c77d4ef
--- /dev/null
+++ b/src/client/encode.hpp
@@ -0,0 +1,15 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <libkazv-config.hpp>
+
+#include <string>
+
+namespace Kazv
+{
+ std::string percentEncode(std::string url);
+}
diff --git a/src/client/status-utils.cpp b/src/client/status-utils.cpp
index c89729f..07d38b7 100644
--- a/src/client/status-utils.cpp
+++ b/src/client/status-utils.cpp
@@ -1,36 +1,43 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include "status-utils.hpp"
namespace Kazv
{
+ namespace detail {
+ EffectStatus ReturnEffectStatusT::effectStatus() const
+ {
+ return st;
+ }
+ }
+
detail::ReturnEffectStatusT failWithResponse(const BaseJob::Response &r)
{
auto code = r.errorCode();
auto msg = r.errorMessage();
auto d = json{
{"error", msg},
{"errorCode", code},
};
return detail::ReturnEffectStatusT{
EffectStatus(/* succ = */ false, JsonWrap(d))
};
}
detail::ReturnEffectStatusT failEffect(std::string errorCode, std::string errorMsg)
{
auto d = json{
{"error", errorMsg},
{"errorCode", errorCode},
};
return detail::ReturnEffectStatusT{
EffectStatus(/* succ = */ false, JsonWrap(d))
};
}
}
diff --git a/src/client/status-utils.hpp b/src/client/status-utils.hpp
index d40ef5f..7f290a8 100644
--- a/src/client/status-utils.hpp
+++ b/src/client/status-utils.hpp
@@ -1,51 +1,53 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <context.hpp>
#include <basejob.hpp>
namespace Kazv
{
namespace detail
{
struct SimpleFailT
{
template<class Context>
EffectStatus operator()(Context &&) const {
return EffectStatus(/* succ = */ false);
};
};
struct ReturnEffectStatusT
{
EffectStatus st;
template<class Context>
EffectStatus operator()(Context &&) const
{
return st;
};
+
+ EffectStatus effectStatus() const;
};
}
/**
* A effect that returns a failed EffectStatus upon invocation.
*/
constexpr auto simpleFail = detail::SimpleFailT{};
/**
* A effect that returns a failed EffectStatus upon invocation.
*/
detail::ReturnEffectStatusT failWithResponse(const BaseJob::Response &r);
/**
* An effect that returns a failed EffectStatus with the given
* error code and message.
*/
detail::ReturnEffectStatusT failEffect(std::string errorCode, std::string errorMsg);
}
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index d24a830..31ca7b8 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -1,123 +1,125 @@
include(CTest)
set(KAZVTEST_RESPATH ${CMAKE_CURRENT_SOURCE_DIR}/resources)
configure_file(kazvtest-respath.hpp.in kazvtest-respath.hpp)
function(libkazv_add_tests)
set(options "")
set(oneValueArgs "")
set(multiValueArgs EXTRA_LINK_LIBRARIES EXTRA_INCLUDE_DIRECTORIES)
cmake_parse_arguments(PARSE_ARGV 0 libkazv_add_tests "${options}" "${oneValueArgs}" "${multiValueArgs}")
foreach(test_source ${libkazv_add_tests_UNPARSED_ARGUMENTS})
string(REGEX REPLACE "\\.cpp$" "" test_executable "${test_source}")
string(REGEX REPLACE "/|\\\\" "--" test_executable "${test_executable}")
message(STATUS "Test ${test_executable} added")
add_executable("${test_executable}" "${test_source}")
target_link_libraries("${test_executable}"
PRIVATE Catch2::Catch2WithMain
Threads::Threads
${libkazv_add_tests_EXTRA_LINK_LIBRARIES}
)
target_include_directories(
"${test_executable}"
PRIVATE ${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/..
${libkazv_add_tests_EXTRA_INCLUDE_DIRECTORIES}
)
target_compile_definitions("${test_executable}" PRIVATE CATCH_CONFIG_ENABLE_ALL_STRINGMAKERS)
add_test(NAME "${test_executable}" COMMAND "${test_executable}" "--allow-running-no-tests" "~[needs-internet]")
endforeach()
endfunction()
libkazv_add_tests(
event-test.cpp
cursorutiltest.cpp
base/serialization-test.cpp
base/types-test.cpp
base/immer-utils-test.cpp
base/json-utils-test.cpp
EXTRA_LINK_LIBRARIES kazvbase
)
add_library(client-test-lib SHARED client/client-test-util.cpp)
target_link_libraries(client-test-lib PUBLIC kazvjob kazvclient)
libkazv_add_tests(
client/discovery-test.cpp
client/sync-test.cpp
client/content-test.cpp
client/paginate-test.cpp
client/util-test.cpp
client/serialization-test.cpp
client/encrypted-file-test.cpp
client/sdk-test.cpp
client/thread-safety-test.cpp
client/room-test.cpp
client/random-generator-test.cpp
client/profile-test.cpp
client/kick-test.cpp
client/ban-test.cpp
client/join-test.cpp
client/keys-test.cpp
client/device-ops-test.cpp
client/send-test.cpp
client/encryption-test.cpp
client/redact-test.cpp
client/tagging-test.cpp
client/account-data-test.cpp
client/room/room-actions-test.cpp
client/room/local-echo-test.cpp
client/room/event-relationships-test.cpp
client/room/member-membership-test.cpp
client/push-rules-desc-test.cpp
client/notification-handler-test.cpp
client/validator-test.cpp
client/power-levels-desc-test.cpp
client/client-test.cpp
client/create-room-test.cpp
client/device-list-tracker-test.cpp
client/device-list-tracker-benchmark-test.cpp
client/room/read-receipt-test.cpp
client/room/undecrypted-events-test.cpp
client/encryption-benchmark-test.cpp
client/logout-test.cpp
client/room/pinned-events-test.cpp
client/get-versions-test.cpp
+ client/alias-test.cpp
+ client/encode-test.cpp
EXTRA_LINK_LIBRARIES kazvclient kazveventemitter kazvjob client-test-lib kazvtestfixtures
EXTRA_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/client
)
libkazv_add_tests(
basejobtest.cpp
kazvjobtest.cpp
file-desc-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazvjob
)
libkazv_add_tests(
promise-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazvjob kazvstore
)
libkazv_add_tests(
event-emitter-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazveventemitter
)
libkazv_add_tests(
crypto-test.cpp
crypto/inbound-group-session-test.cpp
crypto/outbound-group-session-test.cpp
crypto/session-test.cpp
crypto/key-export-test.cpp
EXTRA_LINK_LIBRARIES kazvcrypto
)
libkazv_add_tests(
store-test.cpp
EXTRA_LINK_LIBRARIES kazvstore kazvjob
)
diff --git a/src/tests/client/account-data-test.cpp b/src/tests/client/account-data-test.cpp
index 4b8f862..3fef73f 100644
--- a/src/tests/client/account-data-test.cpp
+++ b/src/tests/client/account-data-test.cpp
@@ -1,396 +1,637 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <catch2/catch_test_macros.hpp>
#include <lager/event_loop/boost_asio.hpp>
#include <boost/asio.hpp>
#include <cprjobhandler.hpp>
#include <asio-promise-handler.hpp>
#include <lagerstoreeventemitter.hpp>
#include <cursorutil.hpp>
#include <sdk-model.hpp>
#include <client/client.hpp>
#include "client-test-util.hpp"
#include "action-mock-utils.hpp"
#include "factory.hpp"
using namespace Kazv::Factory;
Event accountDataEvent = R"({
"type": "moe.kazv.mxc.kazv.some-event",
"content": {
"test": 1
}
})"_json;
TEST_CASE("Send set account data by room job", "[client][account-data]")
{
ClientModel loggedInModel = makeClient({});
auto [resModel, dontCareEffect] = ClientModel::update(
loggedInModel, SetAccountDataPerRoomAction{"!room:example.com", accountDataEvent});
assert1Job(resModel);
for1stJob(resModel, [] (const auto &job) {
REQUIRE(job.jobId() == "SetAccountDataPerRoom");
REQUIRE(job.url().find("/rooms/!room:example.com") != std::string::npos);
REQUIRE(job.url().find("/account_data/moe.kazv.mxc.kazv.some-event") != std::string::npos);
auto jsonBody = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(jsonBody == accountDataEvent.content().get());
});
}
TEST_CASE("Process account data by room response", "[client][account-data]")
{
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
WHEN("Success response")
{
auto succResponse = makeResponse("SetAccountDataPerRoom");
store.dispatch(ProcessResponseAction{succResponse})
.then([] (auto stat) {
REQUIRE(stat.success());
});
}
WHEN("Failed response")
{
auto failResponse = makeResponse("SetAccountDataPerRoom", withResponseJsonBody(R"({
"errcode": "M_FORBIDDEN",
"error": "Cannot add account data for other users."
})"_json));
failResponse.statusCode = 403;
store.dispatch(ProcessResponseAction{failResponse})
.then([] (auto stat) {
REQUIRE(!stat.success());
REQUIRE(stat.dataStr("error") == "Cannot add account data for other users.");
REQUIRE(stat.dataStr("errorCode") == "M_FORBIDDEN");
});
}
io.run();
}
TEST_CASE("Room::setAccountData()", "[client][account-data]")
{
boost::asio::io_context io;
SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!room:example.com")
))
);
auto jh = Kazv::CprJobHandler{io.get_executor()};
auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
auto sdk = Kazv::makeSdk(
SdkModel{m},
jh,
ee,
Kazv::AsioPromiseHandler{io.get_executor()},
zug::identity
);
auto ctx = sdk.context();
auto dispatcher = getMockDispatcher(
ph,
ctx,
returnEmpty<SetAccountDataPerRoomAction>()
);
auto mockContext = getMockContext(ph, dispatcher);
auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
auto r = client.room("!room:example.com");
r.setAccountData(accountDataEvent)
.then([&io](auto) {
io.stop();
});
io.run();
REQUIRE(dispatcher.template calledTimes<SetAccountDataPerRoomAction>() == 1);
auto a = dispatcher.template of<SetAccountDataPerRoomAction>()[0];
REQUIRE(a.roomId == "!room:example.com");
REQUIRE(a.accountDataEvent == accountDataEvent);
}
Event tagEvent = R"({
"type": "m.tag",
"content": {
"tags": {
"m.favourite": 0
}
}
})"_json;
TEST_CASE("Room::addOrSetTag()", "[client][account-data][tagging]")
{
boost::asio::io_context io;
SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!room:example.com")
| withRoomAccountData({tagEvent})
))
);
auto jh = Kazv::CprJobHandler{io.get_executor()};
auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
auto sdk = Kazv::makeSdk(
SdkModel{m},
jh,
ee,
Kazv::AsioPromiseHandler{io.get_executor()},
zug::identity
);
auto ctx = sdk.context();
auto expectedEvent = Event();
auto dispatcher = getMockDispatcher(
ph,
ctx,
returnEmpty<SetAccountDataPerRoomAction>()
);
auto mockContext = getMockContext(ph, dispatcher);
auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
auto r = client.room("!room:example.com");
SECTION("adding new tag")
{
auto expectedJson = tagEvent.raw().get();
expectedJson["content"]["tags"]["u.xxx"] = {{"order", 0.2}};
expectedEvent = expectedJson;
r.addOrSetTag("u.xxx", 0.2)
.then([&io](auto) {
io.stop();
});
}
SECTION("adding new tag, no order")
{
auto expectedJson = tagEvent.raw().get();
expectedJson["content"]["tags"]["u.xxx"] = json::object();
expectedEvent = expectedJson;
r.addOrSetTag("u.xxx")
.then([&io](auto) {
io.stop();
});
}
SECTION("updating existing tag")
{
auto expectedJson = tagEvent.raw().get();
expectedJson["content"]["tags"]["m.favourite"] = {{"order", 0.5}};
expectedEvent = expectedJson;
r.addOrSetTag("m.favourite", 0.5)
.then([&io](auto) {
io.stop();
});
}
SECTION("updating existing tag, no order")
{
auto expectedJson = tagEvent.raw().get();
expectedJson["content"]["tags"]["m.favourite"] = json::object();
expectedEvent = expectedJson;
r.addOrSetTag("m.favourite")
.then([&io](auto) {
io.stop();
});
}
io.run();
REQUIRE(dispatcher.template calledTimes<SetAccountDataPerRoomAction>() == 1);
auto a = dispatcher.template of<SetAccountDataPerRoomAction>()[0];
REQUIRE(a.roomId == "!room:example.com");
REQUIRE(a.accountDataEvent == expectedEvent);
}
TEST_CASE("Room::removeTag()", "[client][account-data][tagging]")
{
boost::asio::io_context io;
SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!room:example.com")
| withRoomAccountData({tagEvent})
))
);
auto jh = Kazv::CprJobHandler{io.get_executor()};
auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
auto sdk = Kazv::makeSdk(
SdkModel{m},
jh,
ee,
Kazv::AsioPromiseHandler{io.get_executor()},
zug::identity
);
auto ctx = sdk.context();
auto expectedEvent = Event();
auto dispatcher = getMockDispatcher(
ph,
ctx,
returnEmpty<SetAccountDataPerRoomAction>()
);
auto mockContext = getMockContext(ph, dispatcher);
auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
auto r = client.room("!room:example.com");
SECTION("removing existing tag")
{
auto expectedJson = tagEvent.raw().get();
expectedJson["content"]["tags"] = json::object();
expectedEvent = expectedJson;
r.removeTag("m.favourite")
.then([&io](auto) {
io.stop();
});
}
SECTION("removing non-existent tag")
{
expectedEvent = tagEvent;
r.removeTag("u.xxx")
.then([&io](auto) {
io.stop();
});
}
io.run();
REQUIRE(dispatcher.template calledTimes<SetAccountDataPerRoomAction>() == 1);
auto a = dispatcher.template of<SetAccountDataPerRoomAction>()[0];
REQUIRE(a.roomId == "!room:example.com");
REQUIRE(a.accountDataEvent == expectedEvent);
}
TEST_CASE("Send set account data job", "[client][account-data]")
{
ClientModel loggedInModel = makeClient({});
auto [resModel, dontCareEffect] = ClientModel::update(
loggedInModel, SetAccountDataAction{accountDataEvent});
assert1Job(resModel);
for1stJob(resModel, [loggedInModel] (const auto &job) {
REQUIRE(job.jobId() == "SetAccountData");
REQUIRE(job.url().find("/user/" + loggedInModel.userId) != std::string::npos);
REQUIRE(job.url().find("/account_data/moe.kazv.mxc.kazv.some-event") != std::string::npos);
auto jsonBody = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(jsonBody == accountDataEvent.content().get());
});
}
TEST_CASE("Process account data response", "[client][account-data]")
{
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
WHEN("Success response")
{
auto succResponse = makeResponse("SetAccountData");
store.dispatch(ProcessResponseAction{succResponse})
.then([] (auto stat) {
REQUIRE(stat.success());
});
}
WHEN("Failed response")
{
auto failResponse = makeResponse("SetAccountData", withResponseJsonBody(R"({
"errcode": "M_FORBIDDEN",
"error": "Cannot add account data for other users."
})"_json));
failResponse.statusCode = 403;
store.dispatch(ProcessResponseAction{failResponse})
.then([] (auto stat) {
REQUIRE(!stat.success());
REQUIRE(stat.dataStr("error") == "Cannot add account data for other users.");
REQUIRE(stat.dataStr("errorCode") == "M_FORBIDDEN");
});
}
io.run();
}
TEST_CASE("Client::accountData()", "[client][account-data]")
{
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
ClientModel m = makeClient(
withAccountData({accountDataEvent})
);
auto store = createTestClientStoreFrom(m, ph);
auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), store, std::nullopt);
REQUIRE(client.accountData().make().get() == m.accountData);
}
TEST_CASE("Client::setAccountData()", "[client][account-data]")
{
boost::asio::io_context io;
SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
ClientModel m = makeClient({});
auto jh = Kazv::CprJobHandler{io.get_executor()};
auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
auto sdk = Kazv::makeSdk(
SdkModel{m},
jh,
ee,
Kazv::AsioPromiseHandler{io.get_executor()},
zug::identity
);
auto ctx = sdk.context();
auto dispatcher = getMockDispatcher(
ph,
ctx,
returnEmpty<SetAccountDataAction>()
);
auto mockContext = getMockContext(ph, dispatcher);
auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
client.setAccountData(accountDataEvent)
.then([&io](auto) {
io.stop();
});
io.run();
REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 1);
auto a = dispatcher.template of<SetAccountDataAction>()[0];
REQUIRE(a.accountDataEvent == accountDataEvent);
}
+
+TEST_CASE("Client::addDirectRoom()", "[client][account-data]")
+{
+ boost::asio::io_context io;
+ SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
+
+ ClientModel m = makeClient({});
+ auto jh = Kazv::CprJobHandler{io.get_executor()};
+ auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
+
+ auto sdk = Kazv::makeSdk(
+ SdkModel{m},
+ jh,
+ ee,
+ Kazv::AsioPromiseHandler{io.get_executor()},
+ zug::identity
+ );
+
+ auto ctx = sdk.context();
+ auto dispatcher = getMockDispatcher(
+ ph,
+ ctx,
+ returnEmpty<SetAccountDataAction>()
+ );
+ auto mockContext = getMockContext(ph, dispatcher);
+
+ auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
+ client.addDirectRoom("@mew:example.org", "!somewhere:example.org")
+ .then([&io](auto) {
+ io.stop();
+ });
+
+ io.run();
+
+ REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 1);
+ auto a = dispatcher.template of<SetAccountDataAction>()[0];
+ Event mDirectEvent = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere:example.org"]
+ }
+ })"_json;
+ REQUIRE(a.accountDataEvent == mDirectEvent);
+}
+
+TEST_CASE("Client::addDirectRoom(), when roomId is already in m.direct", "[client][account-data]")
+{
+ boost::asio::io_context io;
+ SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
+
+ ClientModel m = makeClient({});
+ Event mDirectEvent = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere:example.org"]
+ }
+ })"_json;
+ m.accountData = m.accountData.set("m.direct", mDirectEvent);
+ auto jh = Kazv::CprJobHandler{io.get_executor()};
+ auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
+
+ auto sdk = Kazv::makeSdk(
+ SdkModel{m},
+ jh,
+ ee,
+ Kazv::AsioPromiseHandler{io.get_executor()},
+ zug::identity
+ );
+
+ auto ctx = sdk.context();
+ auto dispatcher = getMockDispatcher(
+ ph,
+ ctx,
+ returnEmpty<SetAccountDataAction>()
+ );
+ auto mockContext = getMockContext(ph, dispatcher);
+
+ auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
+ client.addDirectRoom("@mew:example.org", "!somewhere:example.org")
+ .then([&io](auto) {
+ io.stop();
+ });
+
+ io.run();
+
+ REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 0);
+}
+
+TEST_CASE("Client::addDirectRoom(), when m.direct content is ill-format", "[client][account-data]")
+{
+ boost::asio::io_context io;
+ SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
+
+ ClientModel m = makeClient({});
+ Event mDirectEvent = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": "!somewrongwhere:example.org"
+ }
+ })"_json;
+ m.accountData = m.accountData.set("m.direct", mDirectEvent);
+ auto jh = Kazv::CprJobHandler{io.get_executor()};
+ auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
+
+ auto sdk = Kazv::makeSdk(
+ SdkModel{m},
+ jh,
+ ee,
+ Kazv::AsioPromiseHandler{io.get_executor()},
+ zug::identity
+ );
+
+ auto ctx = sdk.context();
+ auto dispatcher = getMockDispatcher(
+ ph,
+ ctx,
+ returnEmpty<SetAccountDataAction>()
+ );
+ auto mockContext = getMockContext(ph, dispatcher);
+
+ auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
+ client.addDirectRoom("@mew:example.org", "!somewhere:example.org")
+ .then([&io](auto) {
+ io.stop();
+ });
+
+ io.run();
+
+ REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 1);
+ auto a = dispatcher.template of<SetAccountDataAction>()[0];
+ Event mDirectEventNew = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere:example.org"]
+ }
+ })"_json;
+ REQUIRE(a.accountDataEvent == mDirectEventNew);
+}
+
+TEST_CASE("Client::addDirectRoom(), when m.direct content contains other user's rooms", "[client][account-data]")
+{
+ boost::asio::io_context io;
+ SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
+
+ ClientModel m = makeClient({});
+ Event mDirectEvent = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew2:example.org": ["!somewhere2:example.org", "!somewhere3:example.org"]
+ }
+ })"_json;
+ m.accountData = m.accountData.set("m.direct", mDirectEvent);
+ auto jh = Kazv::CprJobHandler{io.get_executor()};
+ auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
+
+ auto sdk = Kazv::makeSdk(
+ SdkModel{m},
+ jh,
+ ee,
+ Kazv::AsioPromiseHandler{io.get_executor()},
+ zug::identity
+ );
+
+ auto ctx = sdk.context();
+ auto dispatcher = getMockDispatcher(
+ ph,
+ ctx,
+ returnEmpty<SetAccountDataAction>()
+ );
+ auto mockContext = getMockContext(ph, dispatcher);
+
+ auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
+ client.addDirectRoom("@mew:example.org", "!somewhere:example.org")
+ .then([&io](auto) {
+ io.stop();
+ });
+
+ io.run();
+
+ REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 1);
+ auto a = dispatcher.template of<SetAccountDataAction>()[0];
+ Event mDirectEventNew = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere:example.org"],
+ "@mew2:example.org": ["!somewhere2:example.org", "!somewhere3:example.org"]
+ }
+ })"_json;
+ REQUIRE(a.accountDataEvent == mDirectEventNew);
+}
+
+TEST_CASE("Client::addDirectRoom(), when m.direct content contains the user's other rooms", "[client][account-data]")
+{
+ boost::asio::io_context io;
+ SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
+
+ ClientModel m = makeClient({});
+ Event mDirectEvent = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere4:example.org"]
+ }
+ })"_json;
+ m.accountData = m.accountData.set("m.direct", mDirectEvent);
+ auto jh = Kazv::CprJobHandler{io.get_executor()};
+ auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
+
+ auto sdk = Kazv::makeSdk(
+ SdkModel{m},
+ jh,
+ ee,
+ Kazv::AsioPromiseHandler{io.get_executor()},
+ zug::identity
+ );
+
+ auto ctx = sdk.context();
+ auto dispatcher = getMockDispatcher(
+ ph,
+ ctx,
+ returnEmpty<SetAccountDataAction>()
+ );
+ auto mockContext = getMockContext(ph, dispatcher);
+
+ auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
+ client.addDirectRoom("@mew:example.org", "!somewhere:example.org")
+ .then([&io](auto) {
+ io.stop();
+ });
+
+ io.run();
+
+ REQUIRE(dispatcher.template calledTimes<SetAccountDataAction>() == 1);
+ auto a = dispatcher.template of<SetAccountDataAction>()[0];
+ Event mDirectEventNew = R"({
+ "type": "m.direct",
+ "content": {
+ "@mew:example.org": ["!somewhere4:example.org", "!somewhere:example.org"]
+ }
+ })"_json;
+ REQUIRE(a.accountDataEvent == mDirectEventNew);
+}
diff --git a/src/tests/client/alias-test.cpp b/src/tests/client/alias-test.cpp
new file mode 100644
index 0000000..e5da7e9
--- /dev/null
+++ b/src/tests/client/alias-test.cpp
@@ -0,0 +1,78 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <libkazv-config.hpp>
+
+#include <factory.hpp>
+#include <alias.hpp>
+#include <encode.hpp>
+
+#include <catch2/catch_test_macros.hpp>
+#include <nlohmann/json.hpp>
+
+#include <string>
+
+using namespace std::string_literals;
+using namespace Kazv::Factory;
+using namespace Kazv;
+using namespace nlohmann;
+
+TEST_CASE("Get room id by room alias", "[client][room-alias]")
+{
+ const auto roomAlias = "#room:example.org"s;
+ const auto client = makeClient();
+ const auto job = getRoomIdByAliasJob(client, roomAlias);
+ REQUIRE(job.jobId() == "GetRoomIdByAlias");
+ REQUIRE(job.url().find(percentEncode(roomAlias))
+ != std::string::npos);
+}
+
+TEST_CASE("Parse GetRoomIdByAlias response", "[client][room-alias]")
+{
+ WHEN("Success response")
+ {
+ const auto roomId = "!room:example.org"s;
+ const auto servers = json::array({"server1.org", "server2.org"});
+ const auto succResponse = makeResponse("GetRoomIdByAlias",
+ withResponseJsonBody(json{
+ {"room_id", roomId},
+ {"servers", servers}}));
+ const auto stat = parseGetRoomIdByAliasResponse(succResponse);
+ REQUIRE(stat.success());
+ REQUIRE(stat.dataStr("roomId") == roomId);
+ REQUIRE(stat.dataJson("servers") == servers);
+ }
+
+ WHEN("Failed 400 response")
+ {
+ const auto errorMsg = "Room alias invalid"s;
+ const auto errorCode = "M_INVALID_PARAM"s;
+ const auto failResponse = makeResponse("GetRoomIdByAlias",
+ withResponseJsonBody(json{
+ {"errorcode", errorCode},
+ {"error", errorMsg}})
+ | withResponseStatusCode(400));
+ const auto stat = parseGetRoomIdByAliasResponse(failResponse);
+ REQUIRE(!stat.success());
+ REQUIRE(stat.dataStr("error") == errorMsg);
+ REQUIRE(stat.dataStr("errorCode") == "400");
+ }
+
+ WHEN("Failed 404 response")
+ {
+ const auto errorMsg = "Room alias #room:example.org not found."s;
+ const auto errorCode = "M_NOT_FOUND"s;
+ const auto failResponse = makeResponse("GetRoomIdByAlias",
+ withResponseJsonBody(json{
+ {"errorcode", errorCode},
+ {"error", errorMsg}})
+ | withResponseStatusCode(404));
+ const auto stat = parseGetRoomIdByAliasResponse(failResponse);
+ REQUIRE(!stat.success());
+ REQUIRE(stat.dataStr("error") == errorMsg);
+ REQUIRE(stat.dataStr("errorCode") == "404");
+ }
+}
diff --git a/src/tests/client/create-room-test.cpp b/src/tests/client/create-room-test.cpp
index 0ad0fcb..5503823 100644
--- a/src/tests/client/create-room-test.cpp
+++ b/src/tests/client/create-room-test.cpp
@@ -1,198 +1,200 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <lager/event_loop/boost_asio.hpp>
#include <catch2/catch_test_macros.hpp>
#include <boost/asio.hpp>
#include <asio-promise-handler.hpp>
#include <cprjobhandler.hpp>
#include <lagerstoreeventemitter.hpp>
#include <cursorutil.hpp>
#include <sdk-model.hpp>
#include <client/client.hpp>
#include <client/actions/membership.hpp>
#include "client-test-util.hpp"
#include "action-mock-utils.hpp"
#include "factory.hpp"
using namespace Kazv;
using namespace Kazv::Factory;
TEST_CASE("Client::createRoom()", "[client][create-room]")
{
ClientModel m = makeClient();
boost::asio::io_context io;
SingleTypePromiseInterface<EffectStatus> ph{AsioPromiseHandler{io.get_executor()}};
auto jh = Kazv::CprJobHandler{io.get_executor()};
auto ee = Kazv::LagerStoreEventEmitter(lager::with_boost_asio_event_loop{io.get_executor()});
auto sdk = Kazv::makeSdk(
SdkModel{m},
jh,
ee,
Kazv::AsioPromiseHandler{io.get_executor()},
zug::identity
);
auto ctx = sdk.context();
auto dispatcher = getMockDispatcher(
ph,
ctx,
returnEmpty<CreateRoomAction>()
);
auto mockContext = getMockContext(ph, dispatcher);
auto client = Client(Client::InEventLoopTag{}, mockContext, sdk.context());
auto initialState = immer::array<Event>{
makeEvent(withEventType("moe.kazv.mxc.some-type") | withStateKey(""))
};
client.createRoom(
/* visibility = */ RoomVisibility::Private,
/* name = */ "some name",
/* alias = */ "alias",
/* invite = */ immer::array<std::string>{"@invited:example.com"},
/* isDirect = */ true,
/* allowFederate = */ true,
/* topic = */ "some topic",
/* powerLevelContentOverride = */ json::object({{"messages", 1}}),
/* preset = */ CreateRoomPreset::TrustedPrivateChat,
/* initialState = */ initialState
).then([&io](auto) {
io.stop();
});
io.run();
REQUIRE(dispatcher.template calledTimes<CreateRoomAction>() == 1);
auto action = dispatcher.template of<CreateRoomAction>()[0];
REQUIRE(action.visibility == RoomVisibility::Private);
REQUIRE(action.name == "some name");
REQUIRE(action.roomAliasName == "alias");
REQUIRE(action.invite == immer::array<std::string>{"@invited:example.com"});
REQUIRE(action.isDirect == true);
REQUIRE(action.creationContent.get() == json::object({
{"m.federate", true},
}));
REQUIRE(action.topic == "some topic");
REQUIRE(action.preset == CreateRoomPreset::TrustedPrivateChat);
REQUIRE(action.initialState == initialState);
}
TEST_CASE("CreateRoomAction", "[client][create-room]")
{
ClientModel m = makeClient();
auto action = CreateRoomAction{};
action.visibility = RoomVisibility::Private;
action.name = "some name";
action.roomAliasName = "alias";
action.invite = immer::array<std::string>{"@invited:example.com"};
action.isDirect = true;
action.powerLevelContentOverride = json{{"messages", 1}};
action.creationContent = json::object({
{"m.federate", true},
});
action.topic = "some topic";
action.preset = CreateRoomPreset::TrustedPrivateChat;
action.initialState = immer::array<Event>{
json{
{"type", "moe.kazv.mxc.some-type"},
{"state_key", "@mew:example.com"},
{"content", {
{"foo", "bar"},
}},
},
};
SECTION("base") {
auto [next, _] = updateClient(m, action);
assert1Job(next);
for1stJob(next, [action](const BaseJob &job) {
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body["creation_content"] == json{
{"m.federate", true},
});
REQUIRE(body["initial_state"] == json::array({action.initialState[0].originalJson().get()}));
REQUIRE(body["invite"] == json{"@invited:example.com"});
REQUIRE(!body.contains("invite_3pid"));
REQUIRE(body["is_direct"] == true);
REQUIRE(body["name"] == "some name");
REQUIRE(body["power_level_content_override"] == json{
{"messages", 1},
});
REQUIRE(body["preset"] == "trusted_private_chat");
REQUIRE(body["room_alias_name"] == "alias");
REQUIRE(!body.contains("room_version"));
REQUIRE(body["topic"] == "some topic");
REQUIRE(body["visibility"] == "private");
});
}
WHEN("visibility: public") {
action.visibility = RoomVisibility::Public;
auto [next, _] = updateClient(m, action);
for1stJob(next, [action](const BaseJob &job) {
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body["visibility"] == "public");
});
}
WHEN("preset: public_chat") {
action.preset = CreateRoomPreset::PublicChat;
auto [next, _] = updateClient(m, action);
for1stJob(next, [action](const BaseJob &job) {
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body["preset"] == "public_chat");
});
}
WHEN("preset: private_chat") {
action.preset = CreateRoomPreset::PrivateChat;
auto [next, _] = updateClient(m, action);
for1stJob(next, [action](const BaseJob &job) {
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body["preset"] == "private_chat");
});
}
}
TEST_CASE("Process create room response", "[client][create-room]")
{
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
WHEN("Success response")
{
- auto succResponse = makeResponse("CreateRoom", withResponseJsonBody(json{{"room_id", "!some-room:example.com"}}));
+ const auto roomId = "!some-room:example.com";
+ auto succResponse = makeResponse("CreateRoom", withResponseJsonBody(json{{"room_id", roomId}}));
store.dispatch(ProcessResponseAction{succResponse})
- .then([&](auto stat) {
+ .then([&, roomId](auto stat) {
REQUIRE(stat.success());
+ REQUIRE(stat.dataStr("roomId") == roomId);
});
}
WHEN("Failed response")
{
auto failResponse = makeResponse("CreateRoom", withResponseStatusCode(400) | withResponseJsonBody(json{
{"error", "An unknown error occurred"},
{"errcode", "M_UNKNOWN"},
}));
store.dispatch(ProcessResponseAction{failResponse})
.then([](auto stat) {
REQUIRE(!stat.success());
REQUIRE(stat.dataStr("error") == "An unknown error occurred");
REQUIRE(stat.dataStr("errorCode") == "400");
});
}
io.run();
}
diff --git a/src/tests/client/encode-test.cpp b/src/tests/client/encode-test.cpp
new file mode 100644
index 0000000..a7170ba
--- /dev/null
+++ b/src/tests/client/encode-test.cpp
@@ -0,0 +1,30 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2025 nannanko <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <libkazv-config.hpp>
+
+#include <encode.hpp>
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <vector>
+#include <string>
+
+using namespace std;
+using namespace std::string_literals;
+using namespace Kazv;
+
+TEST_CASE("String percent-encoding", "[client][percent-encoding]")
+{
+ const std::vector<std::pair<string, string>> strs = {
+ {"!#$&'()*+,/:;=?@[]"s, "%21%23%24%26%27%28%29%2a%2b%2c%2f%3a%3b%3d%3f%40%5b%5d"s},
+ {"#room:example.org"s, "%23room%3aexample.org"s},
+ {"Unreserved_characters_string"s, "Unreserved_characters_string"s}
+ };
+ for (auto s : strs) {
+ REQUIRE(percentEncode(s.first) == s.second);
+ }
+}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 9, 3:32 PM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784773
Default Alt Text
(93 KB)
Attached To
Mode
rL libkazv
Attached
Detach File
Event Timeline
Log In to Comment