Page MenuHomePhorge

No OneTemporary

Size
93 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Sat, Oct 10, 5:27 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784773
Default Alt Text
(93 KB)

Event Timeline