From 8e9aa87a08c1009d35c12dd3d93fc0fd365a1af9 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 3 Feb 2024 19:12:00 +0100 Subject: [PATCH] Add a new Channel API for experimental plugins feature (#5141) --- CHANGELOG.md | 1 + docs/chatterino.d.ts | 59 ++- docs/plugin-meta.lua | 161 +++++++- scripts/make_luals_meta.py | 71 +++- src/CMakeLists.txt | 2 + src/common/Channel.hpp | 4 + src/controllers/plugins/LuaAPI.cpp | 91 ----- src/controllers/plugins/LuaAPI.hpp | 139 ++++++- src/controllers/plugins/LuaUtilities.hpp | 13 +- src/controllers/plugins/PluginController.cpp | 10 +- src/controllers/plugins/api/ChannelRef.cpp | 394 +++++++++++++++++++ src/controllers/plugins/api/ChannelRef.hpp | 275 +++++++++++++ src/providers/twitch/TwitchChannel.cpp | 1 + src/providers/twitch/TwitchChannel.hpp | 1 + 14 files changed, 1069 insertions(+), 153 deletions(-) create mode 100644 src/controllers/plugins/api/ChannelRef.cpp create mode 100644 src/controllers/plugins/api/ChannelRef.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 4193063e3..ccb5059bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Minor: Added icons for newer versions of macOS. (#5148) - Minor: Added the `--incognito/--no-incognito` options to the `/openurl` command, allowing you to override the "Open links in incognito/private mode" setting. (#5149) - Minor: Added support for the `{input.text}` placeholder in the **Split** -> **Run a command** hotkey. (#5130) +- Minor: Add a new Channel API for experimental plugins feature. (#5141) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 23cbdc2bb..a2299e682 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -12,13 +12,68 @@ declare module c2 { channel_name: String; } + enum Platform { + Twitch, + } + enum ChannelType { + None, + Direct, + Twitch, + TwitchWhispers, + TwitchWatching, + TwitchMentions, + TwitchLive, + TwitchAutomod, + Irc, + Misc, + } + + interface IWeakResource { + is_valid(): boolean; + } + + class RoomModes { + unique_chat: boolean; + subscriber_only: boolean; + emotes_only: boolean; + follower_only: null | number; + slow_mode: null | number; + } + class StreamStatus { + live: boolean; + viewer_count: number; + uptime: number; + title: string; + game_name: string; + game_id: string; + } + + class Channel implements IWeakResource { + is_valid(): boolean; + get_name(): string; + get_type(): ChannelType; + get_display_name(): string; + send_message(message: string, execute_commands: boolean): void; + add_system_message(message: string): void; + + is_twitch_channel(): boolean; + + get_room_modes(): RoomModes; + get_stream_status(): StreamStatus; + get_twitch_id(): string; + is_broadcaster(): boolean; + is_mod(): boolean; + is_vip(): boolean; + + static by_name(name: string, platform: Platform): null | Channel; + static by_twitch_id(id: string): null | Channel; + } + function log(level: LogLevel, ...data: any[]): void; function register_command( name: String, handler: (ctx: CommandContext) => void ): boolean; - function send_msg(channel: String, text: String): boolean; - function system_msg(channel: String, text: String): boolean; class CompletionList { values: String[]; diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 78bdf759b..efdd47ae4 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -6,6 +6,15 @@ c2 = {} +---@class IWeakResource + +--- Returns true if the channel this object points to is valid. +--- If the object expired, returns false +--- If given a non-Channel object, it errors. +---@return boolean +function IWeakResource:is_valid() end + + ---@alias LogLevel integer ---@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel } c2.LogLevel = {} @@ -20,6 +29,142 @@ c2.EventType = {} ---@class CompletionList ---@field values string[] The completions ---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. +-- Now including data from src/common/Channel.hpp. + +---@alias ChannelType integer +---@type { None: ChannelType } +ChannelType = {} +-- Back to src/controllers/plugins/LuaAPI.hpp. +-- Now including data from src/controllers/plugins/api/ChannelRef.hpp. +--- This enum describes a platform for the purpose of searching for a channel. +--- Currently only Twitch is supported because identifying IRC channels is tricky. + +---@alias Platform integer +---@type { Twitch: Platform } +Platform = {} +---@class Channel: IWeakResource + +--- Returns true if the channel this object points to is valid. +--- If the object expired, returns false +--- If given a non-Channel object, it errors. +--- +---@return boolean success +function Channel:is_valid() end + +--- Gets the channel's name. This is the lowercase login name. +--- +---@return string name +function Channel:get_name() end + +--- Gets the channel's type +--- +---@return ChannelType +function Channel:get_type() end + +--- Get the channel owner's display name. This may contain non-lowercase ascii characters. +--- +---@return string name +function Channel:get_display_name() end + +--- Sends a message to the target channel. +--- Note that this does not execute client-commands. +--- +---@param message string +---@param execute_commands boolean Should commands be run on the text? +function Channel:send_message(message, execute_commands) end + +--- Adds a system message client-side +--- +---@param message string +function Channel:add_system_message(message) 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. +--- +---@return bool +function Channel:is_twitch_channel() end + +--- Twitch Channel specific functions + +--- Returns a copy of the channel mode settings (subscriber only, r9k etc.) +--- +---@return RoomModes +function Channel:get_room_modes() end + +--- Returns a copy of the stream status. +--- +---@return StreamStatus +function Channel:get_stream_status() end + +--- Returns the Twitch user ID of the owner of the channel. +--- +---@return string +function Channel:get_twitch_id() end + +--- Returns true if the channel is a Twitch channel and the user owns it +--- +---@return boolean +function Channel:is_broadcaster() end + +--- Returns true if the channel is a Twitch channel and the user is a moderator in the channel +--- Returns false for broadcaster. +--- +---@return boolean +function Channel:is_mod() end + +--- Returns true if the channel is a Twitch channel and the user is a VIP in the channel +--- Returns false for broadcaster. +--- +---@return boolean +function Channel:is_vip() end + +--- Misc + +---@return string +function Channel:__tostring() end + +--- Static functions + +--- Finds a channel by name. +--- +--- Misc channels are marked as Twitch: +--- - /whispers +--- - /mentions +--- - /watching +--- - /live +--- - /automod +--- +---@param name string Which channel are you looking for? +---@param platform Platform Where to search for the channel? +---@return Channel? +function Channel.by_name(name, platform) end + +--- Finds a channel by the Twitch user ID of its owner. +--- +---@param string id ID of the owner of the channel. +---@return Channel? +function Channel.by_twitch_id(string) end + +---@class RoomModes +---@field unique_chat boolean You might know this as r9kbeta or robot9000. +---@field subscriber_only boolean +---@field emotes_only boolean Whether or not text is allowed in messages. + +--- Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes + +---@field unique_chat number? Time in minutes you need to follow to chat or nil. + +---@field slow_mode number? Time in seconds you need to wait before sending messages or nil. + +---@class StreamStatus +---@field live boolean +---@field viewer_count number +---@field uptime number Seconds since the stream started. +---@field title string Stream title or last stream title +---@field game_name string +---@field game_id string +-- Back to src/controllers/plugins/LuaAPI.hpp. --- Registers a new command called `name` which when executed will call `handler`. --- @@ -34,22 +179,6 @@ function c2.register_command(name, handler) end ---@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. function c2.register_callback(type, func) end ---- Sends a message to `channel` with the specified text. Also executes commands. ---- ---- **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. ---- ----@param channel string The name of the Twitch channel ----@param text string The text to be sent ----@return boolean ok -function c2.send_msg(channel, text) end - ---- Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. ---- ----@param channel string ----@param text string ----@return boolean ok -function c2.system_msg(channel, text) end - --- Writes a message to the Chatterino log. --- ---@param level LogLevel The desired level. diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index 6afa3c5e3..22240da19 100644 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -12,6 +12,8 @@ It assumes comments look like: - Do not have any useful info on '/**' and '*/' lines. - Class members are not allowed to have non-@command lines and commands different from @lua@field +When this scripts sees "@brief", any further lines of the comment will be ignored + Valid commands are: 1. @exposeenum [dotted.name.in_lua.last_part] Define a table with keys of the enum. Values behind those keys aren't @@ -38,42 +40,54 @@ BOILERPLATE = """ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} + +---@class IWeakResource + +--- Returns true if the channel this object points to is valid. +--- If the object expired, returns false +--- If given a non-Channel object, it errors. +---@return boolean +function IWeakResource:is_valid() end + """ repo_root = Path(__file__).parent.parent lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp" lua_meta = repo_root / "docs" / "plugin-meta.lua" -print("Reading from", lua_api_file.relative_to(repo_root)) print("Writing to", lua_meta.relative_to(repo_root)) -with lua_api_file.open("r") as f: - lines = f.read().splitlines() -# Are we in a doc comment? -comment: bool = False -# Last `@lua@param`s seen - for @exposed generation -last_params_names: list[str] = [] -# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier -is_class = False +def process_file(target, out): + print("Reading from", target.relative_to(repo_root)) + with target.open("r") as f: + lines = f.read().splitlines() -# The name of the next enum in lua world -expose_next_enum_as: str | None = None -# Name of the current enum in c++ world, used to generate internal typenames for -current_enum_name: str | None = None + # Are we in a doc comment? + comment: bool = False + # This is set when @brief is encountered, making the rest of the comment be + # ignored + ignore_this_comment: bool = False -with lua_meta.open("w") as out: - out.write(BOILERPLATE[1:]) # skip the newline after triple quote + # Last `@lua@param`s seen - for @exposed generation + last_params_names: list[str] = [] + # Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier + is_class = False - for line in lines: + # The name of the next enum in lua world + expose_next_enum_as: str | None = None + # Name of the current enum in c++ world, used to generate internal typenames for + current_enum_name: str | None = None + for line_num, line in enumerate(lines): line = line.strip() + loc = f'{target.relative_to(repo_root)}:{line_num}' if line.startswith("enum class "): line = line.removeprefix("enum class ") temp = line.split(" ", 2) current_enum_name = temp[0] if not expose_next_enum_as: print( - f"Skipping enum {current_enum_name}, there wasn't a @exposeenum command" + f"{loc} Skipping enum {current_enum_name}, there wasn't a @exposeenum command" ) current_enum_name = None continue @@ -94,7 +108,7 @@ with lua_meta.open("w") as out: out.write(", ") out.write(entry + ": " + current_enum_name) out.write(" }\n" f"{expose_next_enum_as} = {{}}\n") - print(f"Wrote enum {expose_next_enum_as} => {current_enum_name}") + print(f"{loc} Wrote enum {expose_next_enum_as} => {current_enum_name}") current_enum_name = None expose_next_enum_as = None continue @@ -104,28 +118,40 @@ with lua_meta.open("w") as out: continue elif "*/" in line: comment = False + ignore_this_comment = False + if not is_class: out.write("\n") continue if not comment: continue + if ignore_this_comment: + continue line = line.replace("*", "", 1).lstrip() if line == "": out.write("---\n") + elif line.startswith('@brief '): + # Doxygen comment, on a C++ only method + ignore_this_comment = True elif line.startswith("@exposeenum "): expose_next_enum_as = line.split(" ", 1)[1] elif line.startswith("@exposed "): exp = line.replace("@exposed ", "", 1) params = ", ".join(last_params_names) out.write(f"function {exp}({params}) end\n") - print(f"Wrote function {exp}(...)") + print(f"{loc} Wrote function {exp}(...)") last_params_names = [] + elif line.startswith("@includefile "): + filename = line.replace("@includefile ", "", 1) + output.write(f"-- Now including data from src/{filename}.\n") + process_file(repo_root / 'src' / filename, output) + output.write(f'-- Back to {target.relative_to(repo_root)}.\n') elif line.startswith("@lua"): command = line.replace("@lua", "", 1) if command.startswith("@param"): last_params_names.append(command.split(" ", 2)[1]) elif command.startswith("@class"): - print(f"Writing {command}") + print(f"{loc} Writing {command}") if is_class: out.write("\n") is_class = True @@ -140,3 +166,8 @@ with lua_meta.open("w") as out: # note the space difference from the branch above out.write("--- " + line + "\n") + + +with lua_meta.open("w") as output: + output.write(BOILERPLATE[1:]) # skip the newline after triple quote + process_file(lua_api_file, output) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 03f88f602..f7140387d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -220,6 +220,8 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/plugins/api/ChannelRef.cpp + controllers/plugins/api/ChannelRef.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp controllers/plugins/Plugin.cpp diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index de134b121..66e587fff 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -30,6 +30,10 @@ enum class TimeoutStackStyle : int { class Channel : public std::enable_shared_from_this { public: + // This is for Lua. See scripts/make_luals_meta.py + /** + * @exposeenum ChannelType + */ enum class Type { None, Direct, diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 5f4c66dd5..e087fa111 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -126,97 +126,6 @@ int c2_register_callback(lua_State *L) return 0; } -int c2_send_msg(lua_State *L) -{ - QString text; - QString channel; - if (lua_gettop(L) != 2) - { - luaL_error(L, "send_msg needs exactly 2 arguments (channel and text)"); - lua::push(L, false); - return 1; - } - if (!lua::pop(L, &text)) - { - luaL_error( - L, "cannot get text (2nd argument of send_msg, expected a string)"); - lua::push(L, false); - return 1; - } - if (!lua::pop(L, &channel)) - { - luaL_error( - L, - "cannot get channel (1st argument of send_msg, expected a string)"); - lua::push(L, false); - return 1; - } - - const auto chn = getApp()->twitch->getChannelOrEmpty(channel); - if (chn->isEmpty()) - { - auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); - - qCWarning(chatterinoLua) - << "Plugin" << pl->id - << "tried to send a message (using send_msg) to channel" << channel - << "which is not known"; - lua::push(L, false); - return 1; - } - QString message = text; - message = message.replace('\n', ' '); - QString outText = - getIApp()->getCommands()->execCommand(message, chn, false); - chn->sendMessage(outText); - lua::push(L, true); - return 1; -} - -int c2_system_msg(lua_State *L) -{ - if (lua_gettop(L) != 2) - { - luaL_error(L, - "system_msg needs exactly 2 arguments (channel and text)"); - lua::push(L, false); - return 1; - } - QString channel; - QString text; - - if (!lua::pop(L, &text)) - { - luaL_error( - L, - "cannot get text (2nd argument of system_msg, expected a string)"); - lua::push(L, false); - return 1; - } - if (!lua::pop(L, &channel)) - { - luaL_error(L, "cannot get channel (1st argument of system_msg, " - "expected a string)"); - lua::push(L, false); - return 1; - } - - const auto chn = getApp()->twitch->getChannelOrEmpty(channel); - if (chn->isEmpty()) - { - auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); - qCWarning(chatterinoLua) - << "Plugin" << pl->id - << "tried to show a system message (using system_msg) in channel" - << channel << "which is not known"; - lua::push(L, false); - return 1; - } - chn->addMessage(makeSystemMessage(text)); - lua::push(L, true); - return 1; -} - int c2_log(lua_State *L) { auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index 95a998f79..e1f686e21 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -1,8 +1,12 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS + +# include # include +# include +# include # include struct lua_State; @@ -49,6 +53,11 @@ struct CompletionList { bool hideOthers{}; }; +/** + * @includefile common/Channel.hpp + * @includefile controllers/plugins/api/ChannelRef.hpp + */ + /** * Registers a new command called `name` which when executed will call `handler`. * @@ -68,27 +77,6 @@ int c2_register_command(lua_State *L); */ int c2_register_callback(lua_State *L); -/** - * Sends a message to `channel` with the specified text. Also executes commands. - * - * **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. - * - * @lua@param channel string The name of the Twitch channel - * @lua@param text string The text to be sent - * @lua@return boolean ok - * @exposed c2.send_msg - */ -int c2_send_msg(lua_State *L); -/** - * Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. - * - * @lua@param channel string - * @lua@param text string - * @lua@return boolean ok - * @exposed c2.system_msg - */ -int c2_system_msg(lua_State *L); - /** * Writes a message to the Chatterino log. * @@ -107,6 +95,115 @@ int g_print(lua_State *L); int searcherAbsolute(lua_State *L); int searcherRelative(lua_State *L); +// This is a fat pointer that allows us to type check values given to functions needing a userdata. +// Ensure ALL userdata given to Lua are a subclass of this! Otherwise we garbage as a pointer! +struct UserData { + enum class Type { Channel }; + Type type; + bool isWeak; +}; + +template +struct WeakPtrUserData : public UserData { + std::weak_ptr target; + + WeakPtrUserData(std::weak_ptr t) + : UserData() + , target(t) + { + this->type = T; + this->isWeak = true; + } + + static WeakPtrUserData *create(lua_State *L, std::weak_ptr target) + { + void *ptr = lua_newuserdata(L, sizeof(WeakPtrUserData)); + return new (ptr) WeakPtrUserData(target); + } + + static WeakPtrUserData *from(UserData *target) + { + if (!target->isWeak) + { + return nullptr; + } + if (target->type != T) + { + return nullptr; + } + return reinterpret_cast *>(target); + } + + static WeakPtrUserData *from(void *target) + { + return from(reinterpret_cast(target)); + } + + static int destroy(lua_State *L) + { + auto self = WeakPtrUserData::from(lua_touserdata(L, -1)); + // Note it is safe to only check the weakness of the pointer, as + // std::weak_ptr seems to have identical representation regardless of + // what it points to + assert(self->isWeak); + + self->target.reset(); + lua_pop(L, 1); // Lua deallocates the memory for full user data + return 0; + } +}; + +template +struct SharedPtrUserData : public UserData { + std::shared_ptr target; + + SharedPtrUserData(std::shared_ptr t) + : UserData() + , target(t) + { + this->type = T; + this->isWeak = false; + } + + static SharedPtrUserData *create(lua_State *L, + std::shared_ptr target) + { + void *ptr = lua_newuserdata(L, sizeof(SharedPtrUserData)); + return new (ptr) SharedPtrUserData(target); + } + + static SharedPtrUserData *from(UserData *target) + { + if (target->isWeak) + { + return nullptr; + } + if (target->type != T) + { + return nullptr; + } + return reinterpret_cast *>(target); + } + + static SharedPtrUserData *from(void *target) + { + return from(reinterpret_cast(target)); + } + + static int destroy(lua_State *L) + { + auto self = SharedPtrUserData::from(lua_touserdata(L, -1)); + // Note it is safe to only check the weakness of the pointer, as + // std::shared_ptr seems to have identical representation regardless of + // what it points to + assert(!self->isWeak); + + self->target.reset(); + lua_pop(L, 1); // Lua deallocates the memory for full user data + return 0; + } +}; + } // namespace chatterino::lua::api #endif diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index c7bd0270e..4dd25ee19 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -137,6 +137,17 @@ public: /// TEMPLATES +template +StackIdx push(lua_State *L, std::optional val) +{ + if (val.has_value()) + { + return lua::push(L, *val); + } + lua_pushnil(L); + return lua_gettop(L); +} + template bool peek(lua_State *L, std::optional *out, StackIdx idx = -1) { @@ -262,7 +273,7 @@ StackIdx push(lua_State *L, QList vec) * * @return Stack index of newly created string. */ -template >> +template , bool> = true> StackIdx push(lua_State *L, T inp) { std::string_view name = magic_enum::enum_name(inp); diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 14d9131c3..59a2f4956 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -6,6 +6,7 @@ # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" # include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/api/ChannelRef.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "messages/MessageBuilder.hpp" @@ -143,10 +144,8 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, // NOLINTNEXTLINE(*-avoid-c-arrays) static const luaL_Reg c2Lib[] = { - {"system_msg", lua::api::c2_system_msg}, {"register_command", lua::api::c2_register_command}, {"register_callback", lua::api::c2_register_callback}, - {"send_msg", lua::api::c2_send_msg}, {"log", lua::api::c2_log}, {nullptr, nullptr}, }; @@ -164,6 +163,13 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, lua::pushEnumTable(L); lua_setfield(L, c2libIdx, "EventType"); + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "Platform"); + + // Initialize metatables for objects + lua::api::ChannelRef::createMetatable(L); + lua_setfield(L, c2libIdx, "Channel"); + lua_setfield(L, gtable, "c2"); // ban functions diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp new file mode 100644 index 000000000..ecace8df8 --- /dev/null +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -0,0 +1,394 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/ChannelRef.hpp" + +# include "common/Channel.hpp" +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "messages/MessageBuilder.hpp" +# include "providers/twitch/TwitchChannel.hpp" +# include "providers/twitch/TwitchIrcServer.hpp" + +# include +# include + +# include +# include +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(*vararg) + +// NOLINTNEXTLINE(*-avoid-c-arrays) +static const luaL_Reg CHANNEL_REF_METHODS[] = { + {"is_valid", &ChannelRef::is_valid}, + {"get_name", &ChannelRef::get_name}, + {"get_type", &ChannelRef::get_type}, + {"get_display_name", &ChannelRef::get_display_name}, + {"send_message", &ChannelRef::send_message}, + {"add_system_message", &ChannelRef::add_system_message}, + {"is_twitch_channel", &ChannelRef::is_twitch_channel}, + + // Twitch + {"get_room_modes", &ChannelRef::get_room_modes}, + {"get_stream_status", &ChannelRef::get_stream_status}, + {"get_twitch_id", &ChannelRef::get_twitch_id}, + {"is_broadcaster", &ChannelRef::is_broadcaster}, + {"is_mod", &ChannelRef::is_mod}, + {"is_vip", &ChannelRef::is_vip}, + + // misc + {"__tostring", &ChannelRef::to_string}, + + // static + {"by_name", &ChannelRef::get_by_name}, + {"by_twitch_id", &ChannelRef::get_by_twitch_id}, + {nullptr, nullptr}, +}; + +void ChannelRef::createMetatable(lua_State *L) +{ + lua::StackGuard guard(L, 1); + + luaL_newmetatable(L, "c2.Channel"); + lua_pushstring(L, "__index"); + lua_pushvalue(L, -2); // clone metatable + lua_settable(L, -3); // metatable.__index = metatable + + // Generic IWeakResource stuff + lua_pushstring(L, "__gc"); + lua_pushcfunction( + L, (&WeakPtrUserData::destroy)); + lua_settable(L, -3); // metatable.__gc = WeakPtrUserData<...>::destroy + + luaL_setfuncs(L, CHANNEL_REF_METHODS, 0); +} + +ChannelPtr ChannelRef::getOrError(lua_State *L, bool expiredOk) +{ + if (lua_gettop(L) < 1) + { + luaL_error(L, "Called c2.Channel method without a channel object"); + return nullptr; + } + if (lua_isuserdata(L, lua_gettop(L)) == 0) + { + luaL_error( + L, "Called c2.Channel method with a non Channel 'self' argument."); + return nullptr; + } + auto *data = WeakPtrUserData::from( + lua_touserdata(L, lua_gettop(L))); + if (data == nullptr) + { + luaL_error(L, + "Called c2.Channel method with an invalid channel pointer"); + return nullptr; + } + lua_pop(L, 1); + if (data->target.expired()) + { + if (!expiredOk) + { + luaL_error(L, + "Usage of expired c2.Channel object. Underlying " + "resource was freed. Use Channel:is_valid() to check"); + } + return nullptr; + } + return data->target.lock(); +} + +std::shared_ptr ChannelRef::getTwitchOrError(lua_State *L) +{ + auto ref = ChannelRef::getOrError(L); + auto ptr = dynamic_pointer_cast(ref); + if (ptr == nullptr) + { + luaL_error(L, + "c2.Channel Twitch-only operation on non-Twitch channel."); + } + return ptr; +} + +int ChannelRef::is_valid(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L, true); + lua::push(L, that != nullptr); + return 1; +} + +int ChannelRef::get_name(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L); + lua::push(L, that->getName()); + return 1; +} + +int ChannelRef::get_type(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L); + lua::push(L, that->getType()); + return 1; +} + +int ChannelRef::get_display_name(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L); + lua::push(L, that->getDisplayName()); + return 1; +} + +int ChannelRef::send_message(lua_State *L) +{ + if (lua_gettop(L) != 2 && lua_gettop(L) != 3) + { + luaL_error(L, "Channel:send_message needs 1 or 2 arguments (message " + "text and optionally execute_commands flag)"); + return 0; + } + bool execcmds = false; + if (lua_gettop(L) == 3) + { + if (!lua::pop(L, &execcmds)) + { + luaL_error(L, "cannot get execute_commands (2nd argument of " + "Channel:send_message)"); + return 0; + } + } + + QString text; + if (!lua::pop(L, &text)) + { + luaL_error(L, "cannot get text (1st argument of Channel:send_message)"); + return 0; + } + + ChannelPtr that = ChannelRef::getOrError(L); + + text = text.replace('\n', ' '); + if (execcmds) + { + text = getIApp()->getCommands()->execCommand(text, that, false); + } + that->sendMessage(text); + return 0; +} + +int ChannelRef::add_system_message(lua_State *L) +{ + // needs to account for the hidden self argument + if (lua_gettop(L) != 2) + { + luaL_error( + L, "Channel:add_system_message needs exactly 1 argument (message " + "text)"); + return 0; + } + + QString text; + if (!lua::pop(L, &text)) + { + luaL_error( + L, "cannot get text (1st argument of Channel:add_system_message)"); + return 0; + } + ChannelPtr that = ChannelRef::getOrError(L); + text = text.replace('\n', ' '); + that->addMessage(makeSystemMessage(text)); + return 0; +} + +int ChannelRef::is_twitch_channel(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L); + lua::push(L, that->isTwitchChannel()); + return 1; +} + +int ChannelRef::get_room_modes(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + const auto m = tc->accessRoomModes(); + const auto modes = LuaRoomModes{ + .unique_chat = m->r9k, + .subscriber_only = m->submode, + .emotes_only = m->emoteOnly, + .follower_only = (m->followerOnly == -1) + ? std::nullopt + : std::optional(m->followerOnly), + .slow_mode = + (m->slowMode == 0) ? std::nullopt : std::optional(m->slowMode), + + }; + lua::push(L, modes); + return 1; +} + +int ChannelRef::get_stream_status(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + const auto s = tc->accessStreamStatus(); + const auto status = LuaStreamStatus{ + .live = s->live, + .viewer_count = static_cast(s->viewerCount), + .uptime = s->uptimeSeconds, + .title = s->title, + .game_name = s->game, + .game_id = s->gameId, + }; + lua::push(L, status); + return 1; +} + +int ChannelRef::get_twitch_id(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + lua::push(L, tc->roomId()); + return 1; +} + +int ChannelRef::is_broadcaster(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + lua::push(L, tc->isBroadcaster()); + return 1; +} + +int ChannelRef::is_mod(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + lua::push(L, tc->isMod()); + return 1; +} + +int ChannelRef::is_vip(lua_State *L) +{ + auto tc = ChannelRef::getTwitchOrError(L); + lua::push(L, tc->isVip()); + return 1; +} + +int ChannelRef::get_by_name(lua_State *L) +{ + if (lua_gettop(L) != 2) + { + luaL_error(L, "Channel.by_name needs exactly 2 arguments (channel " + "name and platform)"); + lua_pushnil(L); + return 1; + } + LPlatform platform{}; + if (!lua::pop(L, &platform)) + { + luaL_error(L, "cannot get platform (2nd argument of Channel.by_name, " + "expected a string)"); + lua_pushnil(L); + return 1; + } + QString name; + if (!lua::pop(L, &name)) + { + luaL_error(L, + "cannot get channel name (1st argument of Channel.by_name, " + "expected a string)"); + lua_pushnil(L); + return 1; + } + auto chn = getApp()->twitch->getChannelOrEmpty(name); + if (chn->isEmpty()) + { + lua_pushnil(L); + return 1; + } + // pushes onto stack + WeakPtrUserData::create( + L, chn->weak_from_this()); + luaL_getmetatable(L, "c2.Channel"); + lua_setmetatable(L, -2); + return 1; +} + +int ChannelRef::get_by_twitch_id(lua_State *L) +{ + if (lua_gettop(L) != 1) + { + luaL_error( + L, "Channel.by_twitch_id needs exactly 1 arguments (channel owner " + "id)"); + lua_pushnil(L); + return 1; + } + QString id; + if (!lua::pop(L, &id)) + { + luaL_error(L, + "cannot get channel name (1st argument of Channel.by_name, " + "expected a string)"); + lua_pushnil(L); + return 1; + } + auto chn = getApp()->twitch->getChannelOrEmptyByID(id); + if (chn->isEmpty()) + { + lua_pushnil(L); + return 1; + } + // pushes onto stack + WeakPtrUserData::create( + L, chn->weak_from_this()); + luaL_getmetatable(L, "c2.Channel"); + lua_setmetatable(L, -2); + return 1; +} + +int ChannelRef::to_string(lua_State *L) +{ + ChannelPtr that = ChannelRef::getOrError(L, true); + if (that == nullptr) + { + lua_pushstring(L, ""); + return 1; + } + QString formated = QString("").arg(that->getName()); + lua::push(L, formated); + return 1; +} +} // namespace chatterino::lua::api +// NOLINTEND(*vararg) +// +namespace chatterino::lua { +StackIdx push(lua_State *L, const api::LuaRoomModes &modes) +{ + auto out = lua::pushEmptyTable(L, 6); +# define PUSH(field) \ + lua::push(L, modes.field); \ + lua_setfield(L, out, #field) + PUSH(unique_chat); + PUSH(subscriber_only); + PUSH(emotes_only); + PUSH(follower_only); + PUSH(slow_mode); +# undef PUSH + return out; +} + +StackIdx push(lua_State *L, const api::LuaStreamStatus &status) +{ + auto out = lua::pushEmptyTable(L, 6); +# define PUSH(field) \ + lua::push(L, status.field); \ + lua_setfield(L, out, #field) + PUSH(live); + PUSH(viewer_count); + PUSH(uptime); + PUSH(title); + PUSH(game_name); + PUSH(game_id); +# undef PUSH + return out; +} + +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp new file mode 100644 index 000000000..b60c9c72a --- /dev/null +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -0,0 +1,275 @@ +#pragma once +#include "providers/twitch/TwitchChannel.hpp" + +#include +#ifdef CHATTERINO_HAVE_PLUGINS +# include "common/Channel.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) + +/** + * This enum describes a platform for the purpose of searching for a channel. + * Currently only Twitch is supported because identifying IRC channels is tricky. + * @exposeenum Platform + */ +enum class LPlatform { + Twitch, + //IRC, +}; + +/** + * @lua@class Channel: IWeakResource + */ +struct ChannelRef { + static void createMetatable(lua_State *L); + friend class chatterino::PluginController; + + /** + * @brief Get the content of the top object on Lua stack, usually first argument to function as a ChannelPtr. + * If the object given is not a userdatum or the pointer inside that + * userdatum doesn't point to a Channel, a lua error is thrown. + * + * @param expiredOk Should an expired return nullptr instead of erroring + */ + static ChannelPtr getOrError(lua_State *L, bool expiredOk = false); + + /** + * @brief Casts the result of getOrError to std::shared_ptr + * if that fails thows a lua error. + */ + static std::shared_ptr getTwitchOrError(lua_State *L); + +public: + /** + * Returns true if the channel this object points to is valid. + * If the object expired, returns false + * If given a non-Channel object, it errors. + * + * @lua@return boolean success + * @exposed Channel:is_valid + */ + static int is_valid(lua_State *L); + + /** + * Gets the channel's name. This is the lowercase login name. + * + * @lua@return string name + * @exposed Channel:get_name + */ + static int get_name(lua_State *L); + + /** + * Gets the channel's type + * + * @lua@return ChannelType + * @exposed Channel:get_type + */ + static int get_type(lua_State *L); + + /** + * Get the channel owner's display name. This may contain non-lowercase ascii characters. + * + * @lua@return string name + * @exposed Channel:get_display_name + */ + static int get_display_name(lua_State *L); + + /** + * Sends a message to the target channel. + * Note that this does not execute client-commands. + * + * @lua@param message string + * @lua@param execute_commands boolean Should commands be run on the text? + * @exposed Channel:send_message + */ + static int send_message(lua_State *L); + + /** + * Adds a system message client-side + * + * @lua@param message string + * @exposed Channel:add_system_message + */ + static int add_system_message(lua_State *L); + + /** + * 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. + * + * @lua@return bool + * @exposed Channel:is_twitch_channel + */ + static int is_twitch_channel(lua_State *L); + + /** + * Twitch Channel specific functions + */ + + /** + * Returns a copy of the channel mode settings (subscriber only, r9k etc.) + * + * @lua@return RoomModes + * @exposed Channel:get_room_modes + */ + static int get_room_modes(lua_State *L); + + /** + * Returns a copy of the stream status. + * + * @lua@return StreamStatus + * @exposed Channel:get_stream_status + */ + static int get_stream_status(lua_State *L); + + /** + * Returns the Twitch user ID of the owner of the channel. + * + * @lua@return string + * @exposed Channel:get_twitch_id + */ + static int get_twitch_id(lua_State *L); + + /** + * Returns true if the channel is a Twitch channel and the user owns it + * + * @lua@return boolean + * @exposed Channel:is_broadcaster + */ + static int is_broadcaster(lua_State *L); + + /** + * Returns true if the channel is a Twitch channel and the user is a moderator in the channel + * Returns false for broadcaster. + * + * @lua@return boolean + * @exposed Channel:is_mod + */ + static int is_mod(lua_State *L); + + /** + * Returns true if the channel is a Twitch channel and the user is a VIP in the channel + * Returns false for broadcaster. + * + * @lua@return boolean + * @exposed Channel:is_vip + */ + static int is_vip(lua_State *L); + + /** + * Misc + */ + + /** + * @lua@return string + * @exposed Channel:__tostring + */ + static int to_string(lua_State *L); + + /** + * Static functions + */ + + /** + * Finds a channel by name. + * + * Misc channels are marked as Twitch: + * - /whispers + * - /mentions + * - /watching + * - /live + * - /automod + * + * @lua@param name string Which channel are you looking for? + * @lua@param platform Platform Where to search for the channel? + * @lua@return Channel? + * @exposed Channel.by_name + */ + static int get_by_name(lua_State *L); + + /** + * Finds a channel by the Twitch user ID of its owner. + * + * @lua@param string id ID of the owner of the channel. + * @lua@return Channel? + * @exposed Channel.by_twitch_id + */ + static int get_by_twitch_id(lua_State *L); +}; + +// This is a copy of the TwitchChannel::RoomModes structure, except it uses nicer optionals +/** + * @lua@class RoomModes + */ +struct LuaRoomModes { + /** + * @lua@field unique_chat boolean You might know this as r9kbeta or robot9000. + */ + bool unique_chat = false; + + /** + * @lua@field subscriber_only boolean + */ + bool subscriber_only = false; + + /** + * @lua@field emotes_only boolean Whether or not text is allowed in messages. + * Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes + */ + bool emotes_only = false; + + /** + * @lua@field unique_chat number? Time in minutes you need to follow to chat or nil. + */ + std::optional follower_only; + /** + * @lua@field slow_mode number? Time in seconds you need to wait before sending messages or nil. + */ + std::optional slow_mode; +}; + +/** + * @lua@class StreamStatus + */ +struct LuaStreamStatus { + /** + * @lua@field live boolean + */ + bool live = false; + + /** + * @lua@field viewer_count number + */ + int viewer_count = 0; + + /** + * @lua@field uptime number Seconds since the stream started. + */ + int uptime = 0; + + /** + * @lua@field title string Stream title or last stream title + */ + QString title; + + /** + * @lua@field game_name string + */ + QString game_name; + + /** + * @lua@field game_id string + */ + QString game_id; +}; + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api +namespace chatterino::lua { +StackIdx push(lua_State *L, const api::LuaRoomModes &modes); +StackIdx push(lua_State *L, const api::LuaStreamStatus &status); +} // namespace chatterino::lua +#endif diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index a8eace253..e99bdd228 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -467,6 +467,7 @@ void TwitchChannel::updateStreamStatus( auto diff = since.secsTo(QDateTime::currentDateTime()); status->uptime = QString::number(diff / 3600) + "h " + QString::number(diff % 3600 / 60) + "m"; + status->uptimeSeconds = diff; status->rerun = false; status->streamType = stream.type; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index a331812af..cfeef74ce 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -82,6 +82,7 @@ public: QString game; QString gameId; QString uptime; + int uptimeSeconds = 0; QString streamType; };