diff --git a/CHANGELOG.md b/CHANGELOG.md index 826836c4..59c6215e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Minor: Added the ability to filter on messages by the author's external badges (example: `author.external_badges contains "chatterino:Top Donator"` or `author.external_badges contains "frankerfacez:bot"`). (#6709) - Minor: Added Markdown support to user notes. (#6490) - Minor: Moderators and VIPs in shared chats now show their channel badges. (#6653) +- Minor: Added message read/update methods to the `Channel` plugin API. (#6650) - Bugfix: Moderation checks now include the lead moderator badge. (#6642) - Bugfix: Fixed lead moderator badges not being filtered by the `Channel` badge setting. (#6665) - Bugfix: Expose the "Extra extension IDs" setting on non-Windows systems too. (#6509) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index e5af0401..c1e002e4 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -63,6 +63,20 @@ declare namespace c2 { override_flags?: MessageFlag | null ): void; + message_snapshot(n_items: number): Message[]; + last_message(): Message | null; + replace_message(message: Message, replacement: Message): void; + replace_message( + message: Message, + replacement: Message, + hint: number + ): void; + replace_message_at(index: number, replacement: Message): void; + clear_messages(): void; + find_message_by_id(id: string): Message | null; + has_messages(): boolean; + count_messages(): number; + is_twitch_channel(): boolean; get_room_modes(): RoomModes; diff --git a/docs/lua-meta/globals.lua b/docs/lua-meta/globals.lua index d9ef5a1a..e18866d2 100644 --- a/docs/lua-meta/globals.lua +++ b/docs/lua-meta/globals.lua @@ -151,6 +151,57 @@ function c2.Channel:add_system_message(message) end ---@param override_flags? c2.MessageFlag|nil Flags to override the message's flags (some splits might filter for this) function c2.Channel:add_message(message, context, override_flags) end +--- Get a list of messages in this channel (starting from the most recent messages). +--- The snapshot is returned as a usertype that wraps a C++ object. +--- +---@param n_items number Number of messages to retrieve. This is an upper bound, the actual number of messages returned might be lower. +---@return c2.Message[] +function c2.Channel:message_snapshot(n_items) end + +--- Get the most recent message. If this channel doesn't have any message, this returns `nil`. +--- +---@return c2.Message? +function c2.Channel:last_message() end + +--- Replace a specific message with a different one. +--- +---@param message c2.Message The message to replace. +---@param replacement c2.Message The replacement. +function c2.Channel:replace_message(message, replacement) end + +--- Replace a specific message with a different one. +--- +---@param message c2.Message The message to replace. +---@param replacement c2.Message The replacement. +---@param hint number A one-based index (from the start) where the message is probably located. This is checked first. Otherwise the behavior is identical to the overload without this parameter. +function c2.Channel:replace_message(message, replacement, hint) end + +--- Replace a message at an index with a different one. +--- +---@param index number A one-based index (from the start) of the message to replace. +---@param replacement c2.Message The replacement. +function c2.Channel:replace_message_at(index, replacement) end + +--- Remove all messages in this channel. +--- +function c2.Channel:clear_messages() end + +--- Find a message by its ID. +--- +---@param id string +---@return c2.Message? +function c2.Channel:find_message_by_id(id) end + +--- Check if the channel has any messages. +--- +---@return boolean +function c2.Channel:has_messages() end + +--- Count the number of messages in this channel. +--- +---@return number +function c2.Channel:count_messages() end + --- Returns true for twitch channels. --- Compares the channel Type. Note that enum values aren't guaranteed, just --- that they are equal to the exposed enum. diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index c2b06c54..fd859d51 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -442,6 +442,42 @@ Returns `true` if the channel can be moderated by the current user. Returns `true` if the current user is a VIP in the channel. +##### `Channel:message_snapshot(n_items)` + +Get a list of messages in this channel (starting from the most recent messages). +The snapshot is returned as a usertype that wraps a C++ object. +`n_items` is an upper bound, the actual number of messages returned might be lower. + +##### `Channel:last_message()` + +Get the most recent message. If this channel doesn't have any message, this returns `nil`. + +##### `Channel:replace_message(message, replacement[, hint])` + +Replace a specific message with a different one. +`hint` is a one-based index (from the start) where the message is probably located. This is checked first. Otherwise the behavior is identical to the overload without this parameter. + +##### `Channel:replace_message_at(index, replacement)` + +Replace a message at an index with a different one. +`index` is a one-based index (from the start) of the message to replace. + +##### `Channel:clear_messages()` + +Remove all messages in this channel. + +##### `Channel:find_message_by_id(id)` + +Find a message by its ID. If no message is found, `nil` is returned. + +##### `Channel:has_messages()` + +Check if the channel has any messages. + +##### `Channel:count_messages()` + +Count the number of messages in this channel. + #### `HTTPMethod` enum This table describes HTTP methods available to Lua Plugins. The values behind diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 58e151ec..9e476b9a 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -75,6 +75,11 @@ bool Channel::hasMessages() const return !this->messages_.empty(); } +size_t Channel::countMessages() const +{ + return this->messages_.size(); +} + std::vector Channel::getMessageSnapshot() const { return this->messages_.getSnapshot(); @@ -85,6 +90,13 @@ std::vector Channel::getMessageSnapshot(size_t nItems) const return this->messages_.lastN(nItems); } +std::vector Channel::getMessageSnapshotMut(size_t nItems) const +{ + return this->messages_.lastNBy(nItems, [](const auto &msg) { + return std::const_pointer_cast(msg); + }); +} + MessagePtr Channel::getLastMessage() const { auto last = this->messages_.last(); diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 26d02c41..d9a4ad17 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -23,6 +23,7 @@ namespace chatterino { struct Message; using MessagePtr = std::shared_ptr; +using MessagePtrMut = std::shared_ptr; class Channel : public std::enable_shared_from_this, public MessageSink { @@ -87,6 +88,12 @@ public: std::vector getMessageSnapshot() const; std::vector getMessageSnapshot(size_t nItems) const; + /// Essentially the same as #getMessageSnapshot(size_t), but the returned + /// vector holds `std::shared_ptr`. This should only be used in + /// plugins, because they take messages as `Message` but check that they're + /// frozen. + std::vector getMessageSnapshotMut(size_t nItems) const; + /// Returns the last message (the one at the bottom). If the channel has no /// messages, this will return an empty shared pointer. MessagePtr getLastMessage() const; @@ -122,6 +129,8 @@ public: bool hasMessages() const; + size_t countMessages() const; + void applySimilarityFilters(const MessagePtr &message) const final; MessageSinkTraits sinkTraits() const final; diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index 0a2aa8e7..5f5995d4 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -9,6 +9,7 @@ # include "common/Channel.hpp" # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/SolTypes.hpp" +# include "messages/Message.hpp" # include "providers/twitch/TwitchChannel.hpp" # include "providers/twitch/TwitchIrcServer.hpp" # include "util/WeakPtrHelpers.hpp" @@ -119,6 +120,79 @@ void ChannelRef::add_message(std::shared_ptr &message, this->strong()->addMessage(message, ctx, overrideFlags); } +std::vector ChannelRef::message_snapshot(size_t n_items) +{ + return this->strong()->getMessageSnapshotMut(n_items); +} + +MessagePtrMut ChannelRef::last_message() +{ + return std::const_pointer_cast(this->strong()->getLastMessage()); +} + +void ChannelRef::replace_message(const MessagePtrMut &message, + const MessagePtrMut &replacement) +{ + if (!message || !replacement) + { + throw std::runtime_error("Invalid message"); + } + + this->strong()->replaceMessage(message, replacement); +} + +void ChannelRef::replace_message_hint(const MessagePtrMut &message, + const MessagePtrMut &replacement, + size_t hint) +{ + if (!message || !replacement) + { + throw std::runtime_error("Invalid message"); + } + if (hint == 0) + { + throw std::runtime_error("Invalid index"); + } + + this->strong()->replaceMessage(hint - 1, message, replacement); +} + +void ChannelRef::replace_message_at(size_t index, + const MessagePtrMut &replacement) +{ + if (!replacement) + { + throw std::runtime_error("Invalid message"); + } + if (index == 0) + { + throw std::runtime_error("Invalid index"); + } + + this->strong()->replaceMessage(index - 1, replacement); +} + +void ChannelRef::clear_messages() +{ + this->strong()->clearMessages(); +} + +MessagePtrMut ChannelRef::find_message_by_id(const QString &id) +{ + return std::const_pointer_cast( + this->strong()->findMessageByID(id)); +} + +bool ChannelRef::has_messages() +{ + return this->strong()->hasMessages(); +} + +size_t ChannelRef::count_messages() +{ + return this->strong()->countMessages(); +} + bool ChannelRef::is_twitch_channel() { return this->strong()->isTwitchChannel(); @@ -202,10 +276,21 @@ void ChannelRef::createUserType(sol::table &c2) "get_name",&ChannelRef::get_name, "get_type", &ChannelRef::get_type, "get_display_name", &ChannelRef::get_display_name, + "is_twitch_channel", &ChannelRef::is_twitch_channel, + + // Messages "send_message", &ChannelRef::send_message, "add_system_message", &ChannelRef::add_system_message, "add_message", &ChannelRef::add_message, - "is_twitch_channel", &ChannelRef::is_twitch_channel, + "message_snapshot", &ChannelRef::message_snapshot, + "last_message", &ChannelRef::last_message, + "replace_message", sol::overload(&ChannelRef::replace_message, + &ChannelRef::replace_message_hint), + "replace_message_at", &ChannelRef::replace_message_at, + "clear_messages", &ChannelRef::clear_messages, + "find_message_by_id", &ChannelRef::find_message_by_id, + "has_messages", &ChannelRef::has_messages, + "count_messages", &ChannelRef::count_messages, // TwitchChannel "get_room_modes", &ChannelRef::get_room_modes, diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 1e1e9187..f6b863c7 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -85,6 +85,86 @@ public: */ void add_message(std::shared_ptr &message, sol::variadic_args va); + // FIXME: create a separate type for Sol container wrappers + /** + * Get a list of messages in this channel (starting from the most recent messages). + * The snapshot is returned as a usertype that wraps a C++ object. + * + * @lua@param n_items number Number of messages to retrieve. This is an upper bound, the actual number of messages returned might be lower. + * @lua@return c2.Message[] + * @exposed c2.Channel:message_snapshot + */ + std::vector message_snapshot(size_t n_items); + + /** + * Get the most recent message. If this channel doesn't have any message, this returns `nil`. + * + * @lua@return c2.Message? + * @exposed c2.Channel:last_message + */ + MessagePtrMut last_message(); + + /** + * Replace a specific message with a different one. + * + * @lua@param message c2.Message The message to replace. + * @lua@param replacement c2.Message The replacement. + * @exposed c2.Channel:replace_message + */ + void replace_message(const MessagePtrMut &message, + const MessagePtrMut &replacement); + /** + * Replace a specific message with a different one. + * + * @lua@param message c2.Message The message to replace. + * @lua@param replacement c2.Message The replacement. + * @lua@param hint number A one-based index (from the start) where the message is probably located. This is checked first. Otherwise the behavior is identical to the overload without this parameter. + * @exposed c2.Channel:replace_message + */ + void replace_message_hint(const MessagePtrMut &message, + const MessagePtrMut &replacement, size_t hint); + + /** + * Replace a message at an index with a different one. + * + * @lua@param index number A one-based index (from the start) of the message to replace. + * @lua@param replacement c2.Message The replacement. + * @exposed c2.Channel:replace_message_at + */ + void replace_message_at(size_t index, const MessagePtrMut &replacement); + + /** + * Remove all messages in this channel. + * + * @exposed c2.Channel:clear_messages + */ + void clear_messages(); + + /** + * Find a message by its ID. + * + * @lua@param id string + * @lua@return c2.Message? + * @exposed c2.Channel:find_message_by_id + */ + MessagePtrMut find_message_by_id(const QString &id); + + /** + * Check if the channel has any messages. + * + * @lua@return boolean + * @exposed c2.Channel:has_messages + */ + bool has_messages(); + + /** + * Count the number of messages in this channel. + * + * @lua@return number + * @exposed c2.Channel:count_messages + */ + size_t count_messages(); + /** * Returns true for twitch channels. * Compares the channel Type. Note that enum values aren't guaranteed, just diff --git a/src/messages/LimitedQueue.hpp b/src/messages/LimitedQueue.hpp index ef833d5b..1eb2c7b5 100644 --- a/src/messages/LimitedQueue.hpp +++ b/src/messages/LimitedQueue.hpp @@ -56,6 +56,14 @@ public: return this->buffer_.empty(); } + /// Number of items in this container + [[nodiscard]] size_t size() const + { + std::shared_lock lock(this->mutex_); + + return this->buffer_.size(); + } + /// Value Accessors // Copies of values are returned so that references aren't invalidated @@ -335,6 +343,18 @@ public: }; } + template + [[nodiscard]] std::vector lastNBy(size_t nItems, auto &&cb) const + { + std::shared_lock lock(this->mutex_); + std::vector vec; + std::transform( + this->buffer_.end() - std::min(nItems, this->buffer_.size()), + this->buffer_.end(), std::back_inserter(vec), + std::forward(cb)); + return vec; + } + [[nodiscard]] std::vector firstN(size_t nItems) const { std::shared_lock lock(this->mutex_); diff --git a/tests/lua/channel/messages.lua b/tests/lua/channel/messages.lua new file mode 100644 index 00000000..79a389e2 --- /dev/null +++ b/tests/lua/channel/messages.lua @@ -0,0 +1,175 @@ +-- SPDX-FileCopyrightText: 2025 Contributors to Chatterino +-- +-- SPDX-License-Identifier: CC0-1.0 + +local chan = c2.Channel.by_name("mm2pl") +assert(chan) + +---@param ... c2.Message[] +local function add_all(...) + for _, msg in ipairs({ ... }) do + chan:add_message(msg) + end +end + +local tests = { + message_snapshot = function() + local snap = chan:message_snapshot(10) + assert(type(snap) == "userdata") + assert(#snap == 0) + + chan:add_system_message("system message") + snap = chan:message_snapshot(10) + assert(#snap == 1) + + local msgs = {} + for i = 1, 10 do + local msg = c2.Message.new({ id = tostring(i), message_text = "something" }) + chan:add_message(msg) + table.insert(msgs, msg) + end + + snap = chan:message_snapshot(10) + assert(#snap == 10) + for i = 1, 10 do + assert(msgs[i] == snap[i]) + end + end, + last_message = function() + assert(chan:last_message() == nil) + local msg = c2.Message.new({ id = "42" }) + chan:add_message(msg) + assert(chan:last_message() == msg) + + msg = c2.Message.new({ id = "43" }) + chan:add_message(msg) + assert(chan:last_message() == msg) + end, + replace_message = function() + local msg1 = c2.Message.new({ id = "1" }) + local msg2 = c2.Message.new({ id = "2" }) + local msg3 = c2.Message.new({ id = "3" }) + local msg4 = c2.Message.new({ id = "4" }) + local msg5 = c2.Message.new({ id = "5" }) + + chan:replace_message(msg3, msg4) -- noop + + add_all(msg1, msg2, msg3, msg4) + + chan:replace_message(msg3, msg5) + + local snap = chan:message_snapshot(2) + assert(snap[1] == msg5 and snap[2] == msg4) + + chan:replace_message(msg4, msg5, 4) + snap = chan:message_snapshot(2) + assert(snap[1] == msg5 and snap[2] == msg5) + + -- test how we handle duplicate messages + -- currently we replace the first message + chan:replace_message(msg5, msg3) + snap = chan:message_snapshot(2) + assert(snap[1] == msg3 and snap[2] == msg5) + + -- reset and try with a hint + chan:replace_message(msg3, msg5) + snap = chan:message_snapshot(2) + assert(snap[1] == msg5 and snap[2] == msg5) + + chan:replace_message(msg5, msg3, 4) -- hint is one-based + snap = chan:message_snapshot(2) + assert(snap[1] == msg5 and snap[2] == msg3) + end, + replace_message_at = function() + local msg1 = c2.Message.new({ id = "1" }) + local msg2 = c2.Message.new({ id = "2" }) + local msg3 = c2.Message.new({ id = "3" }) + local msg4 = c2.Message.new({ id = "4" }) + local msg5 = c2.Message.new({ id = "5" }) + + chan:replace_message_at(1, msg1) + chan:replace_message_at(2, msg1) + chan:replace_message_at(3, msg1) + + add_all(msg1, msg2, msg3, msg4) + + chan:replace_message_at(1, msg5) + local snap = chan:message_snapshot(4) + assert(snap[1] == msg5 and snap[2] == msg2 and snap[3] == msg3 and snap[4] == msg4) + + chan:replace_message_at(4, msg5) + snap = chan:message_snapshot(4) + assert(snap[1] == msg5 and snap[2] == msg2 and snap[3] == msg3 and snap[4] == msg5) + + chan:replace_message_at(5, msg5) + snap = chan:message_snapshot(4) + assert(snap[1] == msg5 and snap[2] == msg2 and snap[3] == msg3 and snap[4] == msg5) + end, + clear_messages = function() + chan:clear_messages() + assert(not chan:has_messages()) + assert(chan:count_messages() == 0) + + chan:add_system_message("msg") + chan:add_system_message("msg") + chan:add_system_message("msg") + chan:add_message(c2.Message.new({})) + + assert(chan:has_messages()) + assert(chan:count_messages() == 4) + chan:clear_messages() + + assert(not chan:has_messages()) + assert(chan:count_messages() == 0) + end, + find_message_by_id = function() + local msg1 = c2.Message.new({ id = "1" }) + local msg2 = c2.Message.new({ id = "2" }) + local msg3 = c2.Message.new({ id = "3" }) + local msg4 = c2.Message.new({ id = "4" }) + + assert(chan:find_message_by_id("1") == nil) + + add_all(msg1, msg2, msg3, msg4) + assert(chan:find_message_by_id("1") == msg1) + assert(chan:find_message_by_id("2") == msg2) + assert(chan:find_message_by_id("3") == msg3) + assert(chan:find_message_by_id("4") == msg4) + assert(chan:find_message_by_id("5") == nil) + assert(chan:find_message_by_id("") == nil) + + local msg4_dup = c2.Message.new({ id = "4" }) + chan:add_message(msg4_dup) + assert(chan:find_message_by_id("4") == msg4_dup) + + local found = chan:find_message_by_id("4") + assert(found ~= nil) + chan:add_message(found) + assert(chan:find_message_by_id("4") == found) + assert(chan:find_message_by_id("4") == msg4_dup) + assert(chan:find_message_by_id("4") ~= msg4) + end, + has_messages = function() + assert(not chan:has_messages()) + chan:add_system_message("1") + assert(chan:has_messages()) + chan:add_system_message("2") + assert(chan:has_messages()) + end, + count_messages = function() + assert(chan:count_messages() == 0) + chan:add_system_message("1") + assert(chan:count_messages() == 1) + chan:add_system_message("2") + assert(chan:count_messages() == 2) + end, +} + +for name, fn in pairs(tests) do + chan:clear_messages() -- start off without any messages + + local ok, res = pcall(fn) + if not ok then + error(name .. " failed: " .. res) + end +end diff --git a/tests/lua/stylua.toml b/tests/lua/stylua.toml new file mode 100644 index 00000000..394e8846 --- /dev/null +++ b/tests/lua/stylua.toml @@ -0,0 +1 @@ +indent_type = "Spaces" diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index c89640b2..816809a1 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -1579,6 +1579,19 @@ TEST_P(PluginMessageTest, Run) INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest, testing::ValuesIn(discoverLuaTests("message"))); +class PluginChannelTest : public PluginTest, + public ::testing::WithParamInterface +{ +}; +TEST_P(PluginChannelTest, Run) +{ + this->configure(); + runLuaTest("channel", GetParam(), *this->lua); +} + +INSTANTIATE_TEST_SUITE_P(PluginChannel, PluginChannelTest, + testing::ValuesIn(discoverLuaTests("channel"))); + // verify that all snapshots are included TEST(PluginMessageConstructionTest, Integrity) {