feat(plugins): add display name change event to Channel (#6594)

Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl>
This commit is contained in:
Nerixyz
2026-01-29 18:25:39 +01:00
committed by GitHub
parent 60ecf1a8d7
commit a869cd4bd1
19 changed files with 610 additions and 0 deletions
+1
View File
@@ -27,6 +27,7 @@
- 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)
- Minor: Added a display name change event to `Channel` for plugins. (#6594)
- Minor: Added action to reset `/watching`. (#6759)
- Minor: Removed messaging about running flatpak. This is already apparent in the newer Flatpak runtimes. (#6768)
- Minor: Add `/(un)monitor` and `/(un)restrict` commands for moderators. (#6750)
+10
View File
@@ -46,12 +46,22 @@ declare namespace c2 {
game_id: string;
}
class ConnectionHandle {
disconnect(): void;
block(): void;
unblock(): void;
is_blocked(): boolean;
is_connected(): boolean;
}
class Channel implements IWeakResource {
is_valid(): boolean;
get_name(): string;
get_type(): ChannelType;
get_display_name(): string;
on_display_name_changed(cb: () => void): ConnectionHandle;
send_message(message: string, execute_commands: boolean): void;
send_message(message: string): void;
+37
View File
@@ -244,6 +244,12 @@ function c2.Channel:is_vip() end
---@return string
function c2.Channel:__tostring() end
--- Callback when the channel display name changes.
---
---@param cb fun()
---@return c2.ConnectionHandle hdl
function c2.Channel:on_display_name_changed(cb) end
--- Finds a channel by name.
--- Misc channels are marked as Twitch:
--- - /whispers
@@ -264,6 +270,37 @@ function c2.Channel.by_twitch_id(id) end
-- End src/controllers/plugins/api/ChannelRef.hpp
-- Begin src/controllers/plugins/api/ConnectionHandle.hpp
---@class c2.ConnectionHandle
---This type represents a handle to a registration of a callback for an event handler.
---Conceptually, the event has a _connection_ to the callback/handler.
---This handle can be used to modify that connection.
---It does not automatically disconnect the connection when it's destroyed (in `__gc`) -
---`disconnect()` has to be called manually.
c2.ConnectionHandle = {}
---Disconnect the signal
function c2.ConnectionHandle:disconnect() end
---Block events on this connection
function c2.ConnectionHandle:block() end
---Unblock events on this connection
function c2.ConnectionHandle:unblock() end
---Is this connection currently blocked?
---@return boolean is_blocked
function c2.ConnectionHandle:is_blocked() end
---Is this connection still connected?
---@return boolean is_connected
function c2.ConnectionHandle:is_connected() end
-- End src/controllers/plugins/api/ConnectionHandle.hpp
-- Begin src/controllers/plugins/api/HTTPResponse.hpp
---@class c2.HTTPResponse
+37
View File
@@ -352,6 +352,15 @@ saddummys:get_display_name() -- "서새봄냥"
<!-- F Korean Twitch, apparently you were not profitable enough -->
##### `Channel:on_display_name_changed(cb)`
Callback when the channel display name changes. The callback doesn't get any
arguments, use [`Channel:get_display_name`](#channelget_display_name) to get the
updated name.
This returns a [`ConnectionHandle`](#connectionhandle) which can be used to
disconnect the handler.
##### `Channel:send_message(message[, execute_commands])`
Sends a message to the channel with the given text. If `execute_commands` is
@@ -775,6 +784,34 @@ This table describes links available to plugins.
| `JumpToMessage` | ID of the message | Highlight the message with given ID in current split, do nothing if it was not found | n/a |
| `InsertText` | Any text, command or emote | Insert text into split input | n/a |
#### `ConnectionHandle`
This type represents a handle to a registration of a callback for an event handler.
Conceptually, the event has a _connection_ to the callback/handler.
This handle can be used to modify that connection.
It does not automatically disconnect the connection when it's destroyed (in `__gc`) -
[`disconnect()`](#connectionhandledisconnect) has to be called manually.
##### `ConnectionHandle:disconnect()`
Disconnect the signal.
##### `ConnectionHandle:block()`
Block events on this connection.
##### `ConnectionHandle:unblock()`
Unblock events on this connection.
##### `ConnectionHandle:is_blocked()`
Is this connection currently blocked?
##### `ConnectionHandle:is_connected()`
Is this connection still connected?
### Input/Output API
These functions are wrappers for Lua's I/O library. Functions on file pointer
+6
View File
@@ -256,6 +256,8 @@ set(SOURCE_FILES
controllers/plugins/api/Accounts.hpp
controllers/plugins/api/ChannelRef.cpp
controllers/plugins/api/ChannelRef.hpp
controllers/plugins/api/ConnectionHandle.cpp
controllers/plugins/api/ConnectionHandle.hpp
controllers/plugins/api/DebugLibrary.cpp
controllers/plugins/api/DebugLibrary.hpp
controllers/plugins/api/EventType.hpp
@@ -275,6 +277,8 @@ set(SOURCE_FILES
controllers/plugins/api/Message.hpp
controllers/plugins/api/WebSocket.cpp
controllers/plugins/api/WebSocket.hpp
controllers/plugins/ConnectionManager.cpp
controllers/plugins/ConnectionManager.hpp
controllers/plugins/LuaAPI.cpp
controllers/plugins/LuaAPI.hpp
controllers/plugins/LuaUtilities.cpp
@@ -287,6 +291,8 @@ set(SOURCE_FILES
controllers/plugins/PluginMeta.hpp
controllers/plugins/PluginPermission.cpp
controllers/plugins/PluginPermission.hpp
controllers/plugins/PluginRef.cpp
controllers/plugins/PluginRef.hpp
controllers/plugins/SolTypes.cpp
controllers/plugins/SolTypes.hpp
@@ -0,0 +1,36 @@
#include "controllers/plugins/ConnectionManager.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
# include <algorithm>
namespace chatterino::lua {
api::ConnectionHandle ConnectionManager::add(
pajlada::Signals::ScopedConnection &&conn)
{
this->removeDisconnected();
return {
.connection = this->connections.emplace_back(
std::make_shared<pajlada::Signals::ScopedConnection>(
std::move(conn))),
};
}
void ConnectionManager::clear()
{
this->connections.clear();
}
void ConnectionManager::removeDisconnected()
{
auto [first, last] =
std::ranges::remove_if(this->connections, [](const auto &conn) {
return !conn || !conn->isConnected();
});
this->connections.erase(first, last);
}
} // namespace chatterino::lua
#endif
@@ -0,0 +1,46 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/api/ConnectionHandle.hpp"
# include <pajlada/signals/scoped-connection.hpp>
# include <vector>
namespace chatterino::lua {
class PluginWeakRef;
/// A manager for pajlada::Signals connections.
///
/// In contrast to the existing connection managers, such as a regular
/// std::vector, this tries to avoid storing expired connections, because there
/// is only one per plugin. We also store a `std::shared_ptr<ScopedConnection>`
/// to be able to give the plugin a `std::weak_ptr<ScopedConnection>` which it
/// can use to disconnect.
class ConnectionManager
{
public:
ConnectionManager() = default;
api::ConnectionHandle add(pajlada::Signals::ScopedConnection &&conn);
template <typename Signal, typename Callback>
api::ConnectionHandle managedConnect(Signal &signal, Callback cb)
{
return this->add(signal.connect(std::forward<Callback>(cb)));
}
void clear();
private:
void removeDisconnected();
std::vector<std::shared_ptr<pajlada::Signals::ScopedConnection>>
connections;
};
} // namespace chatterino::lua
#endif
+1
View File
@@ -88,6 +88,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev);
* @includefile common/Channel.hpp
* @includefile controllers/plugins/api/Accounts.hpp
* @includefile controllers/plugins/api/ChannelRef.hpp
* @includefile controllers/plugins/api/ConnectionHandle.hpp
* @includefile controllers/plugins/api/HTTPResponse.hpp
* @includefile controllers/plugins/api/HTTPRequest.hpp
* @includefile controllers/plugins/api/Message.hpp
+7
View File
@@ -9,6 +9,7 @@
# include "common/QLogging.hpp"
# include "controllers/commands/CommandController.hpp"
# include "controllers/plugins/PluginPermission.hpp"
# include "controllers/plugins/SignalCallback.hpp"
# include <lua.h>
# include <QLoggingCategory>
@@ -48,6 +49,11 @@ std::unordered_set<QString> Plugin::listRegisteredCommands()
return out;
}
lua::SignalCallback Plugin::createCallback(sol::main_protected_function pfn)
{
return {this->selfRef_.weak(), std::move(pfn)};
}
Plugin::~Plugin()
{
this->onUnloaded();
@@ -57,6 +63,7 @@ Plugin::~Plugin()
QObject::disconnect(timer, nullptr, nullptr, nullptr);
timer->deleteLater();
}
this->selfRef_.destroy();
this->httpRequests.clear();
qCDebug(chatterinoLua) << "Destroyed" << this->activeTimeouts.size()
<< "timers for plugin" << this->id
+14
View File
@@ -7,7 +7,9 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/api/EventType.hpp"
# include "controllers/plugins/api/HTTPRequest.hpp"
# include "controllers/plugins/ConnectionManager.hpp"
# include "controllers/plugins/PluginMeta.hpp"
# include "controllers/plugins/PluginRef.hpp"
# include <boost/signals2/signal.hpp>
# include <QDir>
@@ -29,6 +31,10 @@ namespace chatterino::lua::api {
enum class LogLevel;
} // namespace chatterino::lua::api
namespace chatterino::lua {
struct SignalCallback;
} // namespace chatterino::lua
namespace chatterino {
class Plugin
@@ -43,6 +49,7 @@ public:
, meta(std::move(meta))
, loadDirectory_(loadDirectory)
, state_(state)
, selfRef_(state ? this : nullptr)
{
}
@@ -91,6 +98,8 @@ public:
return it->second;
}
lua::SignalCallback createCallback(sol::main_protected_function pfn);
/**
* If the plugin crashes while evaluating the main file, this function will return the error
*/
@@ -99,6 +108,8 @@ public:
return this->error_;
}
lua::PluginWeakRef weakRef() const;
int addTimeout(QTimer *timer);
void removeTimeout(QTimer *timer);
@@ -119,11 +130,14 @@ public:
boost::signals2::signal<void()> onUnloaded;
boost::signals2::signal<void(lua::api::LogLevel, const QString &)> onLog;
lua::ConnectionManager connections;
private:
QDir loadDirectory_;
lua_State *state_;
lua::PluginRef selfRef_;
QString error_;
// maps command name -> function
@@ -13,6 +13,7 @@
# include "controllers/commands/CommandController.hpp"
# include "controllers/plugins/api/Accounts.hpp"
# include "controllers/plugins/api/ChannelRef.hpp"
# include "controllers/plugins/api/ConnectionHandle.hpp"
# include "controllers/plugins/api/DebugLibrary.hpp"
# include "controllers/plugins/api/HTTPRequest.hpp"
# include "controllers/plugins/api/HTTPResponse.hpp"
@@ -244,6 +245,7 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin)
lua::api::HTTPResponse::createUserType(c2);
lua::api::HTTPRequest::createUserType(c2);
lua::api::WebSocket::createUserType(c2, plugin);
lua::api::ConnectionHandle::createUserType(c2);
lua::api::message::createUserType(c2);
lua::api::createAccounts(c2);
c2["ChannelType"] = lua::createEnumTable<Channel::Type>(lua);
+66
View File
@@ -0,0 +1,66 @@
#include "controllers/plugins/PluginRef.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
# include "Application.hpp"
# include "common/QLogging.hpp"
# include "controllers/plugins/Plugin.hpp"
# include <QMessageBox>
# include <QStringBuilder>
namespace chatterino::lua {
PluginRef::PluginRef(Plugin *plugin)
: shared(plugin, /*deleter=*/[](void *) {})
{
assert(plugin != nullptr);
}
PluginRef::PluginRef(std::shared_ptr<Plugin> plugin)
: shared(std::move(plugin))
{
}
PluginWeakRef PluginRef::weak() const noexcept
{
return {this->shared};
}
Plugin *PluginRef::plugin() const noexcept
{
return this->shared.get();
}
void PluginRef::destroy()
{
auto useCount = this->shared.use_count();
if (useCount > 1)
{
qCWarning(chatterinoLua)
<< "Destroying PluginRef with" << useCount
<< "strong references. Expected one reference (the plugin itself).";
assert(false && "Too many strong references.");
}
this->shared.reset();
}
PluginWeakRef::PluginWeakRef(std::weak_ptr<Plugin> weak)
: weak(std::move(weak))
{
}
PluginRef PluginWeakRef::strong() const noexcept
{
return {this->weak.lock()};
}
bool PluginWeakRef::isAlive() const noexcept
{
return !this->weak.expired();
}
} // namespace chatterino::lua
#endif
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include <memory>
namespace chatterino {
class Plugin;
} // namespace chatterino
namespace chatterino::lua {
class PluginWeakRef;
/// A strong reference to a plugin.
///
/// This reference is similar to a shared pointer with the big difference that
/// it doesn't own the plugin. Destroying all `PluginRef`s will _not_ destroy
/// the plugin (-> the destructor of the shared_ptr does nothing).
///
/// The plugin itself stores one strong reference. This reference is destroyed
/// with destroy() right before the Lua state is closed. At that point, the
/// reference count must be exactly 1 - meaning only the plugin itself has the
/// strong reference.
///
/// Any object that keeps a reference to the plugin or to Lua values should keep
/// a `PluginWeakRef`. For example, callbacks for signals keep this weak
/// reference and avoid interacting with Lua references if the plugin is dead.
/// While accessing a plugin, objects should upgrade their weak reference to a
/// strong one. This way destroy() can detect potentially dangling references.
class PluginRef
{
public:
PluginRef() = default;
PluginRef(PluginRef &&) = default;
PluginRef &operator=(const PluginRef &) = default;
PluginRef &operator=(PluginRef &&) = default;
PluginRef(const PluginRef &) = default;
~PluginRef() = default;
PluginWeakRef weak() const noexcept;
Plugin *plugin() const noexcept;
operator bool() const noexcept
{
return this->shared != nullptr;
}
private:
PluginRef(Plugin *plugin);
PluginRef(std::shared_ptr<Plugin> plugin);
/// To be called by the plugin before the Lua state is closed.
///
/// Resets the reference if there are no other shared references.
/// Otherwise, aborts execution.
void destroy();
std::shared_ptr<Plugin> shared;
friend Plugin;
friend PluginWeakRef;
};
class PluginWeakRef
{
public:
PluginWeakRef() = default;
PluginWeakRef(const PluginWeakRef &) = default;
PluginWeakRef(PluginWeakRef &&) = default;
PluginWeakRef &operator=(const PluginWeakRef &) = default;
PluginWeakRef &operator=(PluginWeakRef &&) = default;
~PluginWeakRef() = default;
PluginRef strong() const noexcept;
bool isAlive() const noexcept;
private:
PluginWeakRef(std::weak_ptr<Plugin> weak);
std::weak_ptr<Plugin> weak;
friend PluginRef;
};
} // namespace chatterino::lua
#endif
@@ -0,0 +1,76 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/PluginRef.hpp"
# include "controllers/plugins/SolTypes.hpp"
# include "debug/AssertInGuiThread.hpp"
namespace chatterino::lua {
struct SignalCallback {
SignalCallback(PluginWeakRef pluginRef, sol::main_protected_function pfn)
: pluginRef(std::move(pluginRef))
, pfn(std::move(pfn))
{
assert(this->pfn.valid());
}
SignalCallback(const SignalCallback &other) = default;
SignalCallback(SignalCallback &&) = default;
SignalCallback &operator=(const SignalCallback &other)
{
if (this == &other)
{
return *this;
}
if (!this->pluginRef.isAlive())
{
// Reset the function reference before we assign a new function to
// it to avoid destroying it.
this->pfn.abandon();
}
this->pluginRef = other.pluginRef;
this->pfn = other.pfn;
return *this;
}
SignalCallback &operator=(SignalCallback &&other) noexcept
{
std::swap(this->pfn, other.pfn);
std::swap(this->pluginRef, other.pluginRef);
return *this;
}
~SignalCallback()
{
assertInGuiThread();
if (!this->pluginRef.isAlive())
{
this->pfn.abandon(); // don't destruct the function in this case
}
}
void operator()(auto &&...args) const
{
assertInGuiThread();
auto strong = this->pluginRef.strong();
if (!strong)
{
assert(false && "faulty signal handling");
return;
}
loggedVoidCall(this->pfn, u"SignalCallback::operator()",
strong.plugin(), std::forward<decltype(args)>(args)...);
}
private:
PluginWeakRef pluginRef;
sol::main_protected_function pfn;
};
} // namespace chatterino::lua
#endif
@@ -8,6 +8,8 @@
# include "Application.hpp"
# include "common/Channel.hpp"
# include "controllers/commands/CommandController.hpp"
# include "controllers/plugins/Plugin.hpp"
# include "controllers/plugins/SignalCallback.hpp"
# include "controllers/plugins/SolTypes.hpp"
# include "messages/Message.hpp"
# include "providers/twitch/TwitchChannel.hpp"
@@ -243,6 +245,15 @@ bool ChannelRef::operator==(const ChannelRef &other) const noexcept
return weakOwnerEquals(this->weak, other.weak);
}
api::ConnectionHandle ChannelRef::on_display_name_changed(
ThisPluginState state, sol::main_protected_function pfn)
{
auto *plugin = state.plugin();
return plugin->connections.managedConnect(
this->strong()->displayNameChanged,
plugin->createCallback(std::move(pfn)));
}
std::optional<ChannelRef> ChannelRef::get_by_name(const QString &name)
{
auto chan = getApp()->getTwitch()->getChannelOrEmpty(name);
@@ -292,6 +303,8 @@ void ChannelRef::createUserType(sol::table &c2)
"has_messages", &ChannelRef::has_messages,
"count_messages", &ChannelRef::count_messages,
"on_display_name_changed", &ChannelRef::on_display_name_changed,
// TwitchChannel
"get_room_modes", &ChannelRef::get_room_modes,
"get_stream_status", &ChannelRef::get_stream_status,
@@ -9,9 +9,15 @@
# include <sol/forward.hpp>
namespace chatterino::lua {
class ThisPluginState;
} // namespace chatterino::lua
namespace chatterino::lua::api {
// NOLINTBEGIN(readability-identifier-naming)
struct ConnectionHandle;
/**
* @includefile providers/twitch/TwitchChannel.hpp
*/
@@ -241,6 +247,16 @@ public:
bool operator==(const ChannelRef &other) const noexcept;
/**
* Callback when the channel display name changes.
*
* @lua@param cb fun()
* @lua@return c2.ConnectionHandle hdl
* @exposed c2.Channel:on_display_name_changed
*/
ConnectionHandle on_display_name_changed(ThisPluginState state,
sol::main_protected_function pfn);
/**
* Static functions
*/
@@ -0,0 +1,55 @@
#include "controllers/plugins/api/ConnectionHandle.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
# include <sol/sol.hpp>
namespace chatterino::lua::api {
void ConnectionHandle::createUserType(sol::table &c2)
{
c2.new_usertype<ConnectionHandle>(
"ConnectionHandle", sol::no_constructor, //
"disconnect",
[](ConnectionHandle &hdl) {
auto locked = hdl.connection.lock();
if (locked)
{
*locked = pajlada::Signals::ScopedConnection{};
}
},
"block",
[](ConnectionHandle &hdl) {
if (auto locked = hdl.connection.lock())
{
locked->block();
}
},
"unblock",
[](ConnectionHandle &hdl) {
if (auto locked = hdl.connection.lock())
{
locked->unblock();
}
},
"is_blocked",
[](ConnectionHandle &hdl) {
if (auto locked = hdl.connection.lock())
{
return locked->isBlocked();
}
return false;
},
"is_connected",
[](ConnectionHandle &hdl) {
if (auto locked = hdl.connection.lock())
{
return locked->isConnected();
}
return false;
});
}
} // namespace chatterino::lua::api
#endif
@@ -0,0 +1,48 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include <pajlada/signals/scoped-connection.hpp>
# include <sol/forward.hpp>
# include <memory>
namespace chatterino::lua::api {
/* @lua-fragment
---@class c2.ConnectionHandle
---This type represents a handle to a registration of a callback for an event handler.
---Conceptually, the event has a _connection_ to the callback/handler.
---This handle can be used to modify that connection.
---It does not automatically disconnect the connection when it's destroyed (in `__gc`) -
---`disconnect()` has to be called manually.
c2.ConnectionHandle = {}
---Disconnect the signal
function c2.ConnectionHandle:disconnect() end
---Block events on this connection
function c2.ConnectionHandle:block() end
---Unblock events on this connection
function c2.ConnectionHandle:unblock() end
---Is this connection currently blocked?
---@return boolean is_blocked
function c2.ConnectionHandle:is_blocked() end
---Is this connection still connected?
---@return boolean is_connected
function c2.ConnectionHandle:is_connected() end
*/
struct ConnectionHandle {
std::weak_ptr<pajlada::Signals::ScopedConnection> connection;
static void createUserType(sol::table &c2);
};
} // namespace chatterino::lua::api
#endif
+50
View File
@@ -1506,6 +1506,56 @@ TEST_F(PluginTest, LuaVersion)
static_assert(LUA_VERSION_NUM >= 504);
}
TEST_F(PluginTest, ChannelOnDisplayNameChanged)
{
this->configure();
bool gotEvent = false;
this->lua->set_function("on_test_event", [&] {
gotEvent = true;
});
auto chan = std::make_shared<MockChannel>("mock");
this->lua->set("chan", lua::api::ChannelRef(chan));
sol::protected_function init = this->lua->script(R"lua(
hdl = nil
return function(chan)
hdl = chan:on_display_name_changed(on_test_event)
end
)lua");
ASSERT_TRUE(init(lua::api::ChannelRef(chan)).valid());
ASSERT_TRUE(this->lua->script("assert(hdl ~= nil)").valid());
// regular delivery
chan->displayNameChanged.invoke();
ASSERT_TRUE(gotEvent);
// blocked connection
ASSERT_TRUE(this->lua->script("hdl:block()").valid());
ASSERT_TRUE(this->lua->script("assert(hdl:is_blocked())").valid());
gotEvent = false;
chan->displayNameChanged.invoke();
ASSERT_FALSE(gotEvent);
// unblocked connection
ASSERT_TRUE(this->lua->script("hdl:unblock()").valid());
ASSERT_TRUE(this->lua->script("assert(not hdl:is_blocked())").valid());
gotEvent = false;
chan->displayNameChanged.invoke();
ASSERT_TRUE(gotEvent);
// disconnect
ASSERT_TRUE(this->lua->script("hdl:disconnect()").valid());
ASSERT_TRUE(this->lua->script("assert(not hdl:is_connected())").valid());
gotEvent = false;
ASSERT_FALSE(gotEvent);
}
class PluginMessageConstructionTest
: public PluginTest,
public ::testing::WithParamInterface<QString>