feat(plugins): allow message introspection (#6353)

This allows users to introspect messages (i.e. look at the elements they're made up of).

Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl>
This commit is contained in:
nerix
2025-12-07 15:52:59 +01:00
committed by GitHub
parent 0aff6fbe83
commit 74b4674c6b
12 changed files with 1836 additions and 210 deletions
+48
View File
@@ -0,0 +1,48 @@
-- These tests mirror the code example from the docs - make sure they work
local tests = {
frozen_messages = function()
local my_msg = c2.Message.new({ id = "foobar" })
assert(not my_msg.frozen)
local chan = c2.Channel.by_name("mm2pl")
assert(chan)
chan:add_message(my_msg)
assert(my_msg.frozen)
end,
element_access = function()
local my_msg = c2.Message.new({
id = "foo",
elements = {
{ type = "text", text = "foo" },
{ type = "text", text = "bar" },
{ type = "text", text = "baz" },
}
})
local elements = my_msg:elements()
assert(#elements == 3)
assert(elements[1].words[1] == "foo")
assert(elements[2].words[1] == "bar")
elements:erase(2) -- erase "bar"
assert(#elements == 2)
assert(elements[1].words[1] == "foo")
assert(elements[2].words[1] == "baz")
end,
append_element = function()
local my_msg = c2.Message.new({ id = "foo" })
local els = my_msg:elements()
assert(#els == 0)
my_msg:append_element({
type = "text",
text = "My text element",
})
assert(#els == 1)
assert(els[1].type == "text")
end
}
for name, fn in pairs(tests) do
local ok, res = pcall(fn)
if not ok then
error(name .. " failed: " .. res)
end
end
+434
View File
@@ -14,6 +14,7 @@
# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep
# include "lib/Snapshot.hpp"
# include "messages/Message.hpp"
# include "messages/MessageElement.hpp"
# include "mocks/BaseApplication.hpp"
# include "mocks/Channel.hpp"
# include "mocks/EmoteController.hpp"
@@ -1018,6 +1019,417 @@ TEST_F(PluginTest, ChannelAddMessage)
ASSERT_EQ(added[5].first, logged[2]);
}
TEST_F(PluginTest, MessageFrozenFlag)
{
configure();
sol::protected_function isFrozenFn = lua->script(R"lua(
return function(msg)
return msg.frozen
end
)lua");
sol::protected_function setFrozenFn = lua->script(R"lua(
return function(msg, val)
msg.frozen = val
end
)lua");
auto liquid = std::make_shared<Message>();
auto res = isFrozenFn(liquid);
ASSERT_TRUE(res.valid());
ASSERT_FALSE(res.get<bool>());
auto frozen = std::make_shared<Message>();
frozen->freeze();
res = isFrozenFn(frozen);
ASSERT_TRUE(res.valid());
ASSERT_TRUE(res.get<bool>());
// we shouldn't be able to modify the flag
ASSERT_FALSE(setFrozenFn(liquid, true).valid());
ASSERT_FALSE(setFrozenFn(liquid, false).valid());
ASSERT_FALSE(setFrozenFn(frozen, true).valid());
ASSERT_FALSE(setFrozenFn(frozen, false).valid());
}
TEST_F(PluginTest, MessageFlagModification)
{
configure();
sol::protected_function pfn = lua->script(R"lua(
return function(msg)
assert(msg.flags == c2.MessageFlag.Debug)
msg.flags = c2.MessageFlag.System
assert(msg.flags == c2.MessageFlag.System)
end
)lua");
sol::protected_function isFrozenFn = lua->script(R"lua(
return function(msg)
return msg.frozen
end
)lua");
auto liquid = std::make_shared<Message>();
liquid->flags = MessageFlag::Debug;
auto res = pfn(liquid);
ASSERT_TRUE(res.valid());
// for the flags, it shouldn't matter if the message is frozen
auto frozen = std::make_shared<Message>();
frozen->flags = MessageFlag::Debug;
frozen->freeze();
res = pfn(frozen);
ASSERT_TRUE(res.valid());
}
TEST_F(PluginTest, MessageModification)
{
configure();
// Test that we can modify properties and that Lua sees the modification
sol::table tests = lua->script(R"lua(
return {
function(msg)
msg.parse_time = 1234567
end,
function(msg)
assert(msg.id == "abc")
msg.id = "1234"
assert(msg.id == "1234")
end,
function(msg)
assert(msg.search_text == "search")
msg.search_text = "query"
assert(msg.search_text == "query")
end,
function(msg)
assert(msg.message_text == "msg")
msg.message_text = "text"
assert(msg.message_text == "text")
end,
function(msg)
assert(msg.login_name == "login")
msg.login_name = "name"
assert(msg.login_name == "name")
end,
function(msg)
assert(msg.display_name == "display")
msg.display_name = "name"
assert(msg.display_name == "name")
end,
function(msg)
assert(msg.localized_name == "localized")
msg.localized_name = "name"
assert(msg.localized_name == "name")
end,
function(msg)
assert(msg.user_id == "id")
msg.user_id = "id"
assert(msg.user_id == "id")
end,
function(msg)
assert(msg.channel_name == "channel")
msg.channel_name = "name"
assert(msg.channel_name == "name")
end,
function(msg)
assert(msg.username_color == "#ffaabbcc")
msg.username_color = "#ccbbaaff"
assert(msg.username_color == "#ccbbaaff")
end,
function(msg)
assert(msg.server_received_time == 1230000)
msg.server_received_time = 1240000
assert(msg.server_received_time == 1240000)
end,
function(msg)
print(msg.highlight_color)
assert(msg.highlight_color == "#ff223344")
msg.highlight_color = "#44332211"
assert(msg.highlight_color == "#44332211")
end,
function(msg)
assert(#msg:elements() == 2)
msg:append_element({ type = "linebreak" })
assert(#msg:elements() == 3)
assert(msg:elements()[3].type == "linebreak")
end,
}
)lua");
auto makeMsg = [] {
auto msg = std::make_shared<Message>();
msg->flags = MessageFlag::Debug;
msg->id = "abc";
msg->searchText = "search";
msg->messageText = "msg";
msg->loginName = "login";
msg->displayName = "display";
msg->localizedName = "localized";
msg->userID = "id";
msg->channelName = "channel";
msg->usernameColor = QColor(0xaabbcc);
msg->serverReceivedTime = QDateTime::fromMSecsSinceEpoch(1230000);
msg->highlightColor = std::make_shared<QColor>(0x223344);
msg->elements.push_back(
std::make_unique<TextElement>("lol", MessageElementFlag::Text));
msg->elements.push_back(
std::make_unique<TextElement>("wow", MessageElementFlag::Text));
return msg;
};
auto liquid = makeMsg();
ASSERT_TRUE(tests.valid());
for (const auto &[_key, cb] : tests)
{
sol::protected_function pf = cb;
auto res = pf(liquid);
if (!res.valid())
{
sol::error err = res;
ASSERT_TRUE(false) << err.what();
}
}
// If the message is frozen, all modifications should fail with an error
auto frozen = makeMsg();
frozen->freeze();
for (const auto &[key, cb] : tests)
{
sol::protected_function pf = cb;
auto res = pf(frozen);
ASSERT_FALSE(res.valid());
sol::error err = res;
ASSERT_EQ(std::string_view(err.what()), "Message is frozen");
}
}
TEST_F(PluginTest, MessageConstness)
{
configure();
sol::protected_function pfn = lua->script(R"lua(
return function(msg)
assert(msg.login_name == "hello")
msg.login_name = "alien"
end
)lua");
auto msg = std::make_shared<Message>();
msg->loginName = "hello";
MessagePtr cmsg = msg;
auto res = pfn(cmsg);
ASSERT_TRUE(res.valid());
cmsg->freeze();
res = pfn(cmsg);
ASSERT_FALSE(res.valid());
}
// Test that we can access properties of message elements
TEST_F(PluginTest, MessageElementAccess)
{
configure();
sol::protected_function pfn = lua->script(R"lua(
return function(msg, idx, prop)
return msg:elements()[idx][prop]
end
)lua");
auto msg = std::make_shared<Message>();
msg->elements.emplace_back(
std::make_unique<TextElement>("my text", MessageElementFlag::Text));
msg->elements.emplace_back(std::make_unique<SingleLineTextElement>(
"single line", MessageElementFlag::Text));
msg->elements.emplace_back(std::make_unique<CircularImageElement>(
ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton));
msg->elements.emplace_back(std::make_unique<MentionElement>(
"display", "login", MessageColor::Text, MessageColor::System));
msg->elements[1]->setTooltip("tooltip");
msg->elements[2]->setTrailingSpace(false);
msg->freeze();
auto getAll = [&](std::string_view key) {
return std::array{
pfn(msg, 1, key).get<sol::object>(),
pfn(msg, 2, key).get<sol::object>(),
pfn(msg, 3, key).get<sol::object>(),
pfn(msg, 4, key).get<sol::object>(),
};
};
auto types = getAll("type");
ASSERT_EQ(types[0].as<std::string>(), "text");
ASSERT_EQ(types[1].as<std::string>(), "single-line-text");
ASSERT_EQ(types[2].as<std::string>(), "circular-image");
ASSERT_EQ(types[3].as<std::string>(), "mention");
auto flags = getAll("flags");
ASSERT_EQ(flags[0].as<MessageElementFlag>(), MessageElementFlag::Text);
ASSERT_EQ(flags[1].as<MessageElementFlag>(), MessageElementFlag::Text);
ASSERT_EQ(flags[2].as<MessageElementFlag>(),
MessageElementFlag::ReplyButton);
ASSERT_EQ(flags[3].as<MessageElementFlag>(),
(MessageElementFlags(MessageElementFlag::Text,
MessageElementFlag::Mention)));
auto tooltips = getAll("tooltip");
ASSERT_EQ(tooltips[0].as<std::string>(), "");
ASSERT_EQ(tooltips[1].as<std::string>(), "tooltip");
ASSERT_EQ(tooltips[2].as<std::string>(), "");
ASSERT_EQ(tooltips[3].as<std::string>(), "");
auto spaces = getAll("trailing_space");
ASSERT_TRUE(spaces[0].as<bool>());
ASSERT_TRUE(spaces[1].as<bool>());
ASSERT_FALSE(spaces[2].as<bool>());
ASSERT_TRUE(spaces[3].as<bool>());
// Properties only found on _some_ elements should not error
// (like non existent properties)
auto paddings = getAll("padding");
ASSERT_TRUE(paddings[0].is<std::nullptr_t>());
ASSERT_TRUE(paddings[1].is<std::nullptr_t>());
ASSERT_EQ(paddings[2].as<int>(), 2);
ASSERT_TRUE(paddings[3].is<std::nullptr_t>());
auto words = getAll("words");
ASSERT_EQ(words[0].as<std::vector<std::string>>(),
(std::vector<std::string>{"my", "text"}));
ASSERT_EQ(words[1].as<std::vector<std::string>>(),
(std::vector<std::string>{"single", "line"}));
ASSERT_TRUE(words[2].is<std::nullptr_t>());
// mention elements are also text elements
ASSERT_EQ(words[3].as<std::vector<std::string>>(),
(std::vector<std::string>{"display"}));
auto userLogins = getAll("user_login_name");
ASSERT_TRUE(userLogins[0].is<std::nullptr_t>());
ASSERT_TRUE(userLogins[1].is<std::nullptr_t>());
ASSERT_TRUE(userLogins[2].is<std::nullptr_t>());
ASSERT_EQ(userLogins[3].as<std::string>(), "login");
auto times = getAll("time");
ASSERT_TRUE(times[0].is<std::nullptr_t>());
ASSERT_TRUE(times[1].is<std::nullptr_t>());
ASSERT_TRUE(times[2].is<std::nullptr_t>());
ASSERT_TRUE(times[3].is<std::nullptr_t>());
auto nonExistent = getAll("non_existent");
ASSERT_TRUE(nonExistent[0].is<std::nullptr_t>());
ASSERT_TRUE(nonExistent[1].is<std::nullptr_t>());
ASSERT_TRUE(nonExistent[2].is<std::nullptr_t>());
ASSERT_TRUE(nonExistent[3].is<std::nullptr_t>());
auto links = getAll("link");
ASSERT_TRUE(links[0].is<Link>());
ASSERT_TRUE(links[1].is<Link>());
ASSERT_TRUE(links[2].is<Link>());
ASSERT_TRUE(links[3].is<Link>());
// test that accessing anything outside the elements vector causes an error
auto res = pfn(msg, 0, "flags");
ASSERT_FALSE(res.valid());
res = pfn(msg, 42, "flags");
ASSERT_FALSE(res.valid());
}
// Test that we can modify properties of message elements
TEST_F(PluginTest, MessageElementModification)
{
configure();
sol::protected_function pfn = lua->script(R"lua(
return function(msg, idx, prop, val)
msg:elements()[idx][prop] = val
end
)lua");
// same as MessageElementAccess...
auto msg = std::make_shared<Message>();
msg->elements.emplace_back(
std::make_unique<TextElement>("my text", MessageElementFlag::Text));
msg->elements.emplace_back(std::make_unique<SingleLineTextElement>(
"single line", MessageElementFlag::Text));
msg->elements.emplace_back(std::make_unique<CircularImageElement>(
ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton));
msg->elements.emplace_back(std::make_unique<MentionElement>(
"display", "login", MessageColor::Text, MessageColor::System));
msg->elements[1]->setTooltip("tooltip");
msg->elements[2]->setTrailingSpace(false);
// ...but we don't freeze the message here
auto setAll = [&](std::string_view key, auto value) {
for (size_t i = 1; i <= 4; i++)
{
EXPECT_TRUE(pfn(msg, i, key, value).valid());
}
};
setAll("tooltip", "tool");
ASSERT_EQ(msg->elements[0]->getTooltip(), "tool");
ASSERT_EQ(msg->elements[1]->getTooltip(), "tool");
ASSERT_EQ(msg->elements[2]->getTooltip(), "tool");
ASSERT_EQ(msg->elements[3]->getTooltip(), "tool");
setAll("trailing_space", false);
ASSERT_FALSE(msg->elements[0]->hasTrailingSpace());
ASSERT_FALSE(msg->elements[1]->hasTrailingSpace());
ASSERT_FALSE(msg->elements[2]->hasTrailingSpace());
ASSERT_FALSE(msg->elements[3]->hasTrailingSpace());
pfn(msg, 1, "link", Link{Link::CopyToClipboard, "foo"});
pfn(msg, 2, "link", Link{Link::CopyToClipboard, "foo"});
pfn(msg, 3, "link", Link{Link::CopyToClipboard, "foo"});
// can't modify links of mention elements
ASSERT_FALSE(
pfn(msg, 4, "link", Link{Link::CopyToClipboard, "foo"}).valid());
ASSERT_EQ(msg->elements[0]->getLink().type, Link::CopyToClipboard);
ASSERT_EQ(msg->elements[0]->getLink().value, "foo");
ASSERT_EQ(msg->elements[0]->getTooltip(), "<b>Copy to clipboard</b>");
ASSERT_EQ(msg->elements[1]->getLink().type, Link::CopyToClipboard);
ASSERT_EQ(msg->elements[1]->getLink().value, "foo");
ASSERT_EQ(msg->elements[2]->getLink().type, Link::CopyToClipboard);
ASSERT_EQ(msg->elements[2]->getLink().value, "foo");
auto expectErr = [&](std::string_view key, auto value) {
for (size_t i = 1; i <= 4; i++)
{
auto result = pfn(msg, i, key, value);
EXPECT_FALSE(result.valid()) << key;
}
};
expectErr("type", "something");
expectErr("trailing_space", "something");
// can't set these types
expectErr("link", Link{Link::ViewThread, "foo"});
expectErr("link", Link{Link::AutoModAllow, "foo"});
// We can't modify these yet
expectErr("padding", 1);
expectErr("background", 0xabcdef12);
expectErr("words", QStringList{"a", "b"});
expectErr("color", 0x1234);
expectErr("style", FontStyle::ChatMedium);
expectErr("lowercase", "abc");
expectErr("original", "or");
expectErr("fallback_color", "system");
expectErr("user_color", "system");
expectErr("user_login_name", "system");
expectErr("time", 42);
// test that accessing anything outside the elements vector causes an error
auto res = pfn(msg, 0, "trailing_space", true);
ASSERT_FALSE(res.valid());
res = pfn(msg, 42, "trailing_space", true);
ASSERT_FALSE(res.valid());
// we can't modify anything on frozen messages
msg->freeze();
expectErr("tooltip", "tool");
expectErr("trailing_space", false);
expectErr("padding", 1);
}
/// Test that both C++ exceptions and luaL_error properly unwind the stack.
TEST_F(PluginTest, LuaUnwind)
{
@@ -1139,6 +1551,28 @@ TEST_P(PluginJsonTest, Run)
INSTANTIATE_TEST_SUITE_P(PluginJson, PluginJsonTest,
testing::ValuesIn(discoverLuaTests("json")));
class PluginMessageTest : public PluginTest,
public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginMessageTest, Run)
{
configure();
auto pfr = lua->safe_script_file(luaTestPath("message", GetParam()));
EXPECT_TRUE(pfr.valid());
if (!pfr.valid())
{
qDebug() << "Test" << GetParam() << "failed:";
sol::error err = pfr;
qDebug() << err.what();
return;
}
}
INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest,
testing::ValuesIn(discoverLuaTests("message")));
// verify that all snapshots are included
TEST(PluginMessageConstructionTest, Integrity)
{