From bf958d0e34e7d8a39dccfcf3c64f72ef0db41f73 Mon Sep 17 00:00:00 2001 From: Luca Matei Pintilie Date: Sun, 7 Dec 2025 15:01:03 +0100 Subject: [PATCH] add CAP LS 302 support and max-lines support for draft/multiline messages --- src/bridge/bridge.cpp | 53 +++++++++++++++++++-------- src/irc/irc_client.cpp | 51 +++++++++++++++++++++++--- src/irc/irc_client.hpp | 5 ++- src/irc/parse_caps.cpp | 56 +++++++++++++++++++++++++++++ src/irc/parse_caps.hpp | 29 +++++++++++++++ src/utils/chunk.hpp | 24 +++++++++++++ tests/chunk.cpp | 49 +++++++++++++++++++++++++ tests/end_to_end/__main__.py | 2 ++ tests/parse_caps.cpp | 70 ++++++++++++++++++++++++++++++++++++ 9 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 src/irc/parse_caps.cpp create mode 100644 src/irc/parse_caps.hpp create mode 100644 src/utils/chunk.hpp create mode 100644 tests/chunk.cpp create mode 100644 tests/parse_caps.cpp diff --git a/src/bridge/bridge.cpp b/src/bridge/bridge.cpp index 003288d..e0d8793 100644 --- a/src/bridge/bridge.cpp +++ b/src/bridge/bridge.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include "result_set_management.hpp" #include #include @@ -242,9 +244,27 @@ void Bridge::send_channel_message(const Iid& iid, const std::string& body, std:: if (id.empty()) id = utils::gen_uuid(); - std::vector messages; - for (const std::string& line: lines) - { + // A message with a lot of lines may need to be split, even if the + // server supports draft/multiline, as we may not be allowed to send + // that many lines all at once. As such check max-lines + // https://ircv3.net/specs/extensions/multiline#capabilities + std::vector> lines_chunked = {lines}; + auto cap_value = irc->get_capability_value("draft/multiline"); + if (cap_value) { + auto cap_value_parsed = parse_cap_keyval(cap_value.value()); + if (cap_value_parsed.contains("max-lines") && + cap_value_parsed.at("max-lines").has_value()) { + std::stringstream max_lines_string( + cap_value_parsed.at("max-lines").value()); + size_t size; + max_lines_string >> size; + if (size > 0) + lines_chunked = utils::chunk(lines, size); + } + } + for (const auto &lines : lines_chunked) { + std::vector messages; + for (const std::string &line : lines) { constexpr auto max_username_size = 10; constexpr auto max_hostname_size = 63; const auto line_size = 512 - 1 - ::strlen("batch=") - uuid.length() - 1 - ::strlen("draft/multiline-concat") - 1 - @@ -264,18 +284,21 @@ void Bridge::send_channel_message(const Iid& iid, const std::string& body, std:: } } - MessageCallback mirror_to_all_resources = [this, iid, uuid, id, nodes_to_reflect, body](const IrcClient* irc, const IrcMessage&) { - for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) - { - auto stanza = this->xmpp.make_muc_message(std::to_string(iid), irc->get_own_nick(), this->make_xmpp_body(body), - this->user_jid + "/" - + resource, uuid, id); - for (const auto& node: nodes_to_reflect) - stanza.add_child(node); - this->xmpp.send_stanza(stanza); - } - }; - irc->send_batch(uuid, "draft/multiline", {iid.get_local()}, messages, std::move(mirror_to_all_resources)); + MessageCallback mirror_to_all_resources = [this, iid, uuid, id, nodes_to_reflect, body](const IrcClient* irc, const IrcMessage&) { + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + { + auto stanza = this->xmpp.make_muc_message(std::to_string(iid), irc->get_own_nick(), this->make_xmpp_body(body), + this->user_jid + "/" + + resource, uuid, id); + for (const auto& node: nodes_to_reflect) + stanza.add_child(node); + this->xmpp.send_stanza(stanza); + } + }; + irc->send_batch(uuid, "draft/multiline", {iid.get_local()}, messages, std::move(mirror_to_all_resources)); + id = utils::gen_uuid(); + uuid = utils::gen_uuid(); + } return; } diff --git a/src/irc/irc_client.cpp b/src/irc/irc_client.cpp index 5e0503e..a3d1973 100644 --- a/src/irc/irc_client.cpp +++ b/src/irc/irc_client.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -322,10 +323,16 @@ void IrcClient::on_connected() } #endif - { - for (const auto &pair : this->capabilities) - this->send_message({ "CAP", {"REQ", pair.first}}); - } + this->send_message({ "CAP", {"LS", "302"}}); + + // If we never get a response from CAP LS start requesting things blindly + TimedEventsManager::instance().add_event(TimedEvent( + std::chrono::steady_clock::now() + 10s, + [this]() { + for (const auto &pair : this->capabilities) + this->send_message({"CAP", {"REQ", pair.first}}); + }, + "cap_fallback_" + this->get_unique_identifier())); this->send_nick_command(this->current_nick); #ifdef USE_DATABASE @@ -1392,6 +1399,30 @@ long int IrcClient::get_throttle_limit() const void IrcClient::on_cap(const IrcMessage &message) { const auto& sub_command = message.arguments[1]; + if (sub_command == "LS") { + /** + * CAP LS 302 can return either a multiline response or a one line response + * - CAP * LS * :multiline + * - CAP * LS :last line + */ + const bool last_cap_ls = message.arguments[2] != "*"; + const auto caps = + last_cap_ls ? message.arguments[2] : message.arguments[3]; + auto caps_parsed = parse_caps(caps); + this->cap_ls_response.merge(caps_parsed); + if (last_cap_ls) { + // The CAP LS response has finished. Cancel the fallback + TimedEventsManager::instance().cancel("cap_fallback_" + this->get_unique_identifier()); + std::erase_if(this->capabilities, [this](auto &pair) { + if (this->cap_ls_response.contains(pair.first)) { + this->send_message({"CAP", {"REQ", pair.first}}); + return false; + } else + return true; + }); + } + return; + } const auto& caps = utils::split(message.arguments[2], ' ', false); for (const auto& cap: caps) { @@ -1477,12 +1508,18 @@ void IrcClient::cap_end() this->bridge.on_irc_client_connected(this->get_hostname()); } -bool IrcClient::has_capability(const std::string& capability) +bool IrcClient::has_capability(const std::string& capability) const { auto it = this->enabled_capabilities.find(capability); return it != this->enabled_capabilities.end(); } + +const std::optional IrcClient::get_capability_value(const std::string& capability) const +{ + return this->cap_ls_response.at(capability); +} + void IrcClient::on_batch(const IrcMessage& message) { if (message.arguments[0][0] == '+') @@ -1558,3 +1595,7 @@ void IrcClient::process_batch(const std::string& reference_tag) } return; } + +const std::string IrcClient::get_unique_identifier() const { + return this->bridge.get_jid() + this->hostname; +} diff --git a/src/irc/irc_client.hpp b/src/irc/irc_client.hpp index 4090ab1..76d6b05 100644 --- a/src/irc/irc_client.hpp +++ b/src/irc/irc_client.hpp @@ -250,6 +250,7 @@ public: private: void cap_end(); void process_batch(const std::string& reference_tag); + const std::string get_unique_identifier() const; public: #ifdef WITH_SASL void on_authenticate(const IrcMessage& message); @@ -334,7 +335,8 @@ public: */ HistoryLimit history_limit; - bool has_capability(const std::string& capability); + bool has_capability(const std::string& capability) const; + const std::optional get_capability_value(const std::string& capability) const; private: /** * The hostname of the server we are connected to. @@ -395,6 +397,7 @@ private: */ SaslState sasl_state{SaslState::unneeded}; #endif + std::unordered_map> cap_ls_response; std::map capabilities; std::unordered_set enabled_capabilities; /** diff --git a/src/irc/parse_caps.cpp b/src/irc/parse_caps.cpp new file mode 100644 index 0000000..7e46e13 --- /dev/null +++ b/src/irc/parse_caps.cpp @@ -0,0 +1,56 @@ +#include "irc/parse_caps.hpp" + +const std::unordered_map> +parse_caps(const std::string &caps) { + std::unordered_map> output; + // caps => multi-prefix sasl=PLAIN,EXTERNAL + // draft/multiline=max-bytes=40000,max-lines=10 + const std::vector &caps_list = utils::split(caps, ' ', false); + for (const std::string &cap : caps_list) { + // cap => multi-prefix + // cap => sasl=PLAIN,EXTERNAL + // cap => draft/multiline=max-bytes=40000,max-lines=10 + const size_t delimiter = cap.find("="); + // delimiter(multi-prefix) => npos + // delimiter(sasl=PLAIN,EXTERNAL) => 5 + // delimiter(draft/multiline=max-bytes=40000,max-lines=10) => 16 + const std::string cap_name = cap.substr(0, delimiter); + std::optional cap_value; + if (delimiter != std::string::npos) + cap_value = cap.substr(delimiter + 1); + output.insert_or_assign(cap_name, cap_value); + } + + return output; +} + +const std::vector parse_cap_val_list(const std::string &value) { + // value(PLAIN,EXTERNAL) => PLAIN,EXTERNAL + return utils::split(value, ',', false); +} + +const std::unordered_map> +parse_cap_keyval(const std::string &value) { + std::unordered_map> output; + // value(PLAIN,EXTERNAL) => PLAIN,EXTERNAL + // value(max-bytes=40000,max-lines=10) => + // max-bytes=40000,max-lines=10 + const std::vector &values_list = utils::split(value, ',', false); + // values_list(PLAIN,EXTERNAL) => { "PLAIN", "EXTERNAL" } + // values_list(draft/multiline=max-bytes=40000,max-lines=10) => { + // "max-bytes=40000", "max-lines=10" } + for (const std::string &value : values_list) { + const size_t value_delimiter = value.find("="); + // key(PLAIN) => PLAIN + // key(max-bytes=40000) => max-bytes + const std::string key = value.substr(0, value_delimiter); + std::optional val; + if (value_delimiter != std::string::npos) { + // val(max-bytes=40000) => 40000 + val = value.substr(value_delimiter + 1); + } + output.insert_or_assign(key, val); + } + + return output; +} diff --git a/src/irc/parse_caps.hpp b/src/irc/parse_caps.hpp new file mode 100644 index 0000000..2899e53 --- /dev/null +++ b/src/irc/parse_caps.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "utils/split.hpp" +#include +#include +#include +#include +#include + +/** + * Parse a list of IRC caps into a map of capabilities + */ +const std::unordered_map> +parse_caps(const std::string &caps); + +/** + * Parse the value of a cap into a list of values + * + * Used for the sasl cap + */ +const std::vector parse_cap_val_list(const std::string &value); + +/** + * Parse the value of a cap into a key-value map + * + * Used for the multiline cap + */ +const std::unordered_map> +parse_cap_keyval(const std::string &value); diff --git a/src/utils/chunk.hpp b/src/utils/chunk.hpp new file mode 100644 index 0000000..f6eb3ef --- /dev/null +++ b/src/utils/chunk.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace utils { +template +inline std::vector> chunk(const std::vector &vector, + const std::size_t size) { + std::vector> output; + std::vector current_chunk; + for (const auto &el : vector) { + current_chunk.push_back(el); + if (current_chunk.size() >= size) { + output.push_back(current_chunk); + current_chunk = {}; + } + } + if (current_chunk.size() != 0) + output.push_back(current_chunk); + + return output; +} + +} // namespace utils diff --git a/tests/chunk.cpp b/tests/chunk.cpp new file mode 100644 index 0000000..f14c352 --- /dev/null +++ b/tests/chunk.cpp @@ -0,0 +1,49 @@ +#include "utils/chunk.hpp" +#include "catch2/catch_test_macros.hpp" +#include + +TEST_CASE("chunk 5") { + const std::vector input = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + const auto output = utils::chunk(input, 5); + + CHECK( output.size() == 2 ); + CHECK( output.at(0).size() == 5 ); + CHECK( output.at(1).size() == 5 ); + + CHECK( output.at(0).at(0) == 1 ); + CHECK( output.at(0).at(1) == 2 ); + + CHECK( output.at(1).at(0) == 6 ); + CHECK( output.at(1).at(1) == 7 ); +} + +TEST_CASE("chunk 7") { + const std::vector input = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + const auto output = utils::chunk(input, 7); + + CHECK( output.size() == 2 ); + CHECK( output.at(0).size() == 7 ); + CHECK( output.at(1).size() == 3 ); + + CHECK( output.at(0).at(0) == 1 ); + CHECK( output.at(0).at(1) == 2 ); + + CHECK( output.at(1).at(0) == 8 ); + CHECK( output.at(1).at(1) == 9 ); +} + +TEST_CASE("chunk 1") { + const std::vector input = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + const auto output = utils::chunk(input, 1); + + CHECK( output.size() == 10 ); + CHECK( output.at(0).size() == 1 ); + CHECK( output.at(1).size() == 1 ); + + CHECK( output.at(0).at(0) == 1 ); + CHECK( output.at(1).at(0) == 2 ); + CHECK( output.at(2).at(0) == 3 ); + CHECK( output.at(3).at(0) == 4 ); + CHECK( output.at(8).at(0) == 9 ); + CHECK( output.at(9).at(0) == 10 ); +} diff --git a/tests/end_to_end/__main__.py b/tests/end_to_end/__main__.py index e1c73bb..8063be5 100644 --- a/tests/end_to_end/__main__.py +++ b/tests/end_to_end/__main__.py @@ -341,6 +341,8 @@ async def main(): print("Waiting for irc server to exit…") irc.stop() await irc.wait() + res = await irc.process.stderr.readline() + irc_output.write(res.decode()) if failures: print("%d test%s failed, please fix %s." % (failures, 's' if failures > 1 else '', diff --git a/tests/parse_caps.cpp b/tests/parse_caps.cpp new file mode 100644 index 0000000..6beb0c3 --- /dev/null +++ b/tests/parse_caps.cpp @@ -0,0 +1,70 @@ +#include "catch2/catch_test_macros.hpp" +#include "irc/parse_caps.hpp" +#include + +TEST_CASE("simple caps") +{ + const auto output = parse_caps("multi-prefix extended-join account-notify"); + CHECK( output.contains("multi-prefix") ); + CHECK( output.contains("extended-join") ); + CHECK( output.contains("account-notify") ); +} + +TEST_CASE("key-value") +{ + const auto output = parse_caps("sasl=PLAIN example.com/a=b"); + CHECK( output.contains("sasl") ); + CHECK( output.contains("example.com/a") ); + + CHECK( output.at("sasl") == "PLAIN" ); + CHECK( output.at("example.com/a") == "b" ); +} + +TEST_CASE("nested key-value") +{ + const auto output = parse_caps("draft/multiline=max-bytes=40000,max-lines=10 example.com/a=foo=bar,b=c"); + CHECK( output.contains("draft/multiline") ); + CHECK( output.contains("example.com/a") ); + + const auto multiline = output.at("draft/multiline"); + const auto a = output.at("example.com/a"); + CHECK( multiline == "max-bytes=40000,max-lines=10" ); + CHECK ( a == "foo=bar,b=c" ); + + const auto multiline_val = parse_cap_keyval(multiline.value()); + const auto a_val = parse_cap_keyval(a.value()); + CHECK( multiline_val.at("max-bytes").has_value() ); + CHECK( multiline_val.at("max-lines").has_value() ); + CHECK( a_val.at("foo").has_value() ); + CHECK( a_val.at("b").has_value() ); + + CHECK( multiline_val.at("max-bytes").value() == "40000" ); + CHECK( multiline_val.at("max-lines").value() == "10" ); + CHECK( a_val.at("foo").value() == "bar" ); + CHECK( a_val.at("b").value() == "c" ); +} + +TEST_CASE("all") +{ + const auto output = parse_caps("message-tags sasl=PLAIN,EXTERNAL draft/multiline=max-bytes=40000,max-lines=10"); + CHECK( output.contains("message-tags") ); + CHECK( output.contains("sasl") ); + CHECK( output.contains("draft/multiline") ); + + const auto multiline = output.at("draft/multiline"); + const auto sasl = output.at("sasl"); + const auto message_tags = output.at("message-tags"); + CHECK( !message_tags.has_value() ); + CHECK( sasl == "PLAIN,EXTERNAL" ); + CHECK( multiline == "max-bytes=40000,max-lines=10" ); + + const auto multiline_val = parse_cap_keyval(multiline.value()); + const auto sasl_val = parse_cap_val_list(sasl.value()); + CHECK( std::find(sasl_val.begin(), sasl_val.end(), "PLAIN") != sasl_val.end() ); + CHECK( std::find(sasl_val.begin(), sasl_val.end(), "EXTERNAL") != sasl_val.end() ); + CHECK( multiline_val.at("max-bytes").has_value() ); + CHECK( multiline_val.at("max-lines").has_value() ); + + CHECK( multiline_val.at("max-bytes").value() == "40000" ); + CHECK( multiline_val.at("max-lines").value() == "10" ); +}