add CAP LS 302 support and max-lines support for draft/multiline messages

This commit is contained in:
Luca Matei Pintilie
2026-01-13 19:20:46 +01:00
committed by mathieui
parent b250237e01
commit bf958d0e34
9 changed files with 318 additions and 21 deletions
+38 -15
View File
@@ -2,6 +2,7 @@
#include <utility>
#include <xmpp/biboumi_component.hpp>
#include <network/poller.hpp>
#include <utils/chunk.hpp>
#include <utils/empty_if_fixed_server.hpp>
#include <utils/encoding.hpp>
#include <utils/tolower.hpp>
@@ -12,6 +13,7 @@
#include <utils/split.hpp>
#include <xmpp/jid.hpp>
#include <database/database.hpp>
#include <irc/parse_caps.hpp>
#include "result_set_management.hpp"
#include <algorithm>
#include <cstring>
@@ -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<IrcMessage> 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<std::vector<std::string>> 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<IrcMessage> 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;
}
+46 -5
View File
@@ -11,6 +11,7 @@
#include <config/config.hpp>
#include <utils/tolower.hpp>
#include <utils/split.hpp>
#include <irc/parse_caps.hpp>
#include <utils/string.hpp>
#include <sstream>
@@ -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<const std::string> 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;
}
+4 -1
View File
@@ -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<const std::string> 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<std::string, std::optional<std::string>> cap_ls_response;
std::map<std::string, Capability> capabilities;
std::unordered_set<std::string> enabled_capabilities;
/**
+56
View File
@@ -0,0 +1,56 @@
#include "irc/parse_caps.hpp"
const std::unordered_map<std::string, std::optional<std::string>>
parse_caps(const std::string &caps) {
std::unordered_map<std::string, std::optional<std::string>> output;
// caps => multi-prefix sasl=PLAIN,EXTERNAL
// draft/multiline=max-bytes=40000,max-lines=10
const std::vector<std::string> &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<std::string> 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<std::string> parse_cap_val_list(const std::string &value) {
// value(PLAIN,EXTERNAL) => PLAIN,EXTERNAL
return utils::split(value, ',', false);
}
const std::unordered_map<std::string, std::optional<std::string>>
parse_cap_keyval(const std::string &value) {
std::unordered_map<std::string, std::optional<std::string>> output;
// value(PLAIN,EXTERNAL) => PLAIN,EXTERNAL
// value(max-bytes=40000,max-lines=10) =>
// max-bytes=40000,max-lines=10
const std::vector<std::string> &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<std::string> 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;
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "utils/split.hpp"
#include <optional>
#include <set>
#include <sstream>
#include <unordered_map>
#include <vector>
/**
* Parse a list of IRC caps into a map of capabilities
*/
const std::unordered_map<std::string, std::optional<std::string>>
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<std::string> 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<std::string, std::optional<std::string>>
parse_cap_keyval(const std::string &value);
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <config/config.hpp>
namespace utils {
template <typename T>
inline std::vector<std::vector<T>> chunk(const std::vector<T> &vector,
const std::size_t size) {
std::vector<std::vector<T>> output;
std::vector<T> 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
+49
View File
@@ -0,0 +1,49 @@
#include "utils/chunk.hpp"
#include "catch2/catch_test_macros.hpp"
#include <vector>
TEST_CASE("chunk 5") {
const std::vector<int> 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<int> 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<int> 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 );
}
+2
View File
@@ -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 '',
+70
View File
@@ -0,0 +1,70 @@
#include "catch2/catch_test_macros.hpp"
#include "irc/parse_caps.hpp"
#include <iterator>
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" );
}