diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index b35e7aea5..3eb1ef633 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -25,7 +25,7 @@ jobs: matrix: os: [macos-13] qt-version: [5.15.2, 6.7.1] - plugins: [false] + plugins: [true] fail-fast: false env: C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 926d5f907..b6b4c6014 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -26,7 +26,7 @@ jobs: matrix: os: [windows-latest] qt-version: [5.15.2, 6.7.1] - plugins: [false] + plugins: [true] skip-artifact: [false] skip-crashpad: [false] fail-fast: false diff --git a/.gitmodules b/.gitmodules index e58a5bbd4..e15a27575 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,6 @@ [submodule "lib/expected-lite"] path = lib/expected-lite url = https://github.com/martinmoene/expected-lite +[submodule "lib/sol2"] + path = lib/sol2 + url = https://github.com/ThePhD/sol2.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a10472c8..3555762ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,8 +108,10 @@ - Dev: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5607) - Dev: `GIFTimer` is no longer initialized in tests. (#5608) - Dev: Emojis now use flags instead of a set of strings for capabilities. (#5616) +- Dev: Move plugins to Sol2. (#5622) - Dev: Refactored static `MessageBuilder` helpers to standalone functions. (#5652) - Dev: Decoupled reply parsing from `MessageBuilder`. (#5660) +- Dev: Refactored IRC message building. (#5663) ## 2.5.1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fb323286..023135891 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,6 +212,8 @@ endif() if (CHATTERINO_PLUGINS) set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") add_subdirectory(lib/lua) + + find_package(Sol2 REQUIRED) endif() if (BUILD_WITH_CRASHPAD) diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index a0e73332e..2677365ed 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -5,7 +5,6 @@ set(benchmark_SOURCES resources/bench.qrc src/Emojis.cpp - src/Highlights.cpp src/FormatTime.cpp src/Helpers.cpp src/LimitedQueue.cpp diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp deleted file mode 100644 index 69c69db49..000000000 --- a/benchmarks/src/Highlights.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "Application.hpp" -#include "common/Channel.hpp" -#include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" -#include "controllers/highlights/HighlightPhrase.hpp" -#include "messages/Message.hpp" -#include "messages/MessageBuilder.hpp" -#include "mocks/BaseApplication.hpp" -#include "mocks/UserData.hpp" -#include "util/Helpers.hpp" - -#include -#include -#include -#include - -using namespace chatterino; - -class BenchmarkMessageBuilder : public MessageBuilder -{ -public: - explicit BenchmarkMessageBuilder( - Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args) - : MessageBuilder(_channel, _ircMessage, _args) - { - } - - virtual MessagePtr build() - { - // PARSE - this->parse(); - this->usernameColor_ = getRandomColor(this->ircMessage->nick()); - - // words - // this->addWords(this->originalMessage_.split(' ')); - - this->message().messageText = this->originalMessage_; - this->message().searchText = this->message().localizedName + " " + - this->userName + ": " + - this->originalMessage_; - return nullptr; - } - - void bench() - { - this->parseHighlights(); - } -}; - -class MockApplication : public mock::BaseApplication -{ -public: - MockApplication() - : highlights(this->settings, &this->accounts) - { - } - - AccountController *getAccounts() override - { - return &this->accounts; - } - HighlightController *getHighlights() override - { - return &this->highlights; - } - - IUserDataController *getUserData() override - { - return &this->userData; - } - - AccountController accounts; - HighlightController highlights; - mock::UserDataController userData; -}; - -static void BM_HighlightTest(benchmark::State &state) -{ - MockApplication mockApplication; - - std::string message = - R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))"; - auto ircMessage = Communi::IrcMessage::fromData(message.c_str(), nullptr); - auto privMsg = dynamic_cast(ircMessage); - assert(privMsg != nullptr); - MessageParseArgs args; - auto emptyChannel = Channel::getEmpty(); - - for (auto _ : state) - { - state.PauseTiming(); - BenchmarkMessageBuilder b(emptyChannel.get(), privMsg, args); - - b.build(); - state.ResumeTiming(); - - b.bench(); - } -} - -BENCHMARK(BM_HighlightTest); diff --git a/cmake/FindSol2.cmake b/cmake/FindSol2.cmake new file mode 100644 index 000000000..be64d000c --- /dev/null +++ b/cmake/FindSol2.cmake @@ -0,0 +1,21 @@ +include(FindPackageHandleStandardArgs) + +find_path(Sol2_INCLUDE_DIR sol/sol.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/sol2/include) + +find_package_handle_standard_args(Sol2 DEFAULT_MSG Sol2_INCLUDE_DIR) + +if (Sol2_FOUND) + add_library(Sol2 INTERFACE IMPORTED) + set_target_properties(Sol2 PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${Sol2_INCLUDE_DIR}" + ) + target_compile_definitions(Sol2 INTERFACE + SOL_ALL_SAFETIES_ON=1 + SOL_USING_CXX_LUA=1 + SOL_NO_NIL=0 + ) + target_link_libraries(Sol2 INTERFACE lua) + add_library(sol2::sol2 ALIAS Sol2) +endif () + +mark_as_advanced(Sol2_INCLUDE_DIR) diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index d4b1ac25b..5a86efca0 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -5,14 +5,23 @@ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} ----@alias c2.LogLevel integer ----@type { Debug: c2.LogLevel, Info: c2.LogLevel, Warning: c2.LogLevel, Critical: c2.LogLevel } +---@alias c2.LogLevel.Debug "c2.LogLevel.Debug" +---@alias c2.LogLevel.Info "c2.LogLevel.Info" +---@alias c2.LogLevel.Warning "c2.LogLevel.Warning" +---@alias c2.LogLevel.Critical "c2.LogLevel.Critical" +---@alias c2.LogLevel c2.LogLevel.Debug|c2.LogLevel.Info|c2.LogLevel.Warning|c2.LogLevel.Critical +---@type { Debug: c2.LogLevel.Debug, Info: c2.LogLevel.Info, Warning: c2.LogLevel.Warning, Critical: c2.LogLevel.Critical } c2.LogLevel = {} ----@alias c2.EventType integer ----@type { CompletionRequested: c2.EventType } +-- Begin src/controllers/plugins/api/EventType.hpp + +---@alias c2.EventType.CompletionRequested "c2.EventType.CompletionRequested" +---@alias c2.EventType c2.EventType.CompletionRequested +---@type { CompletionRequested: c2.EventType.CompletionRequested } c2.EventType = {} +-- End src/controllers/plugins/api/EventType.hpp + ---@class CommandContext ---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. ---@field channel c2.Channel The channel the command was executed in. @@ -29,19 +38,40 @@ c2.EventType = {} -- Begin src/common/Channel.hpp ----@alias c2.ChannelType integer ----@type { None: c2.ChannelType, Direct: c2.ChannelType, Twitch: c2.ChannelType, TwitchWhispers: c2.ChannelType, TwitchWatching: c2.ChannelType, TwitchMentions: c2.ChannelType, TwitchLive: c2.ChannelType, TwitchAutomod: c2.ChannelType, TwitchEnd: c2.ChannelType, Irc: c2.ChannelType, Misc: c2.ChannelType } +---@alias c2.ChannelType.None "c2.ChannelType.None" +---@alias c2.ChannelType.Direct "c2.ChannelType.Direct" +---@alias c2.ChannelType.Twitch "c2.ChannelType.Twitch" +---@alias c2.ChannelType.TwitchWhispers "c2.ChannelType.TwitchWhispers" +---@alias c2.ChannelType.TwitchWatching "c2.ChannelType.TwitchWatching" +---@alias c2.ChannelType.TwitchMentions "c2.ChannelType.TwitchMentions" +---@alias c2.ChannelType.TwitchLive "c2.ChannelType.TwitchLive" +---@alias c2.ChannelType.TwitchAutomod "c2.ChannelType.TwitchAutomod" +---@alias c2.ChannelType.TwitchEnd "c2.ChannelType.TwitchEnd" +---@alias c2.ChannelType.Misc "c2.ChannelType.Misc" +---@alias c2.ChannelType c2.ChannelType.None|c2.ChannelType.Direct|c2.ChannelType.Twitch|c2.ChannelType.TwitchWhispers|c2.ChannelType.TwitchWatching|c2.ChannelType.TwitchMentions|c2.ChannelType.TwitchLive|c2.ChannelType.TwitchAutomod|c2.ChannelType.TwitchEnd|c2.ChannelType.Misc +---@type { None: c2.ChannelType.None, Direct: c2.ChannelType.Direct, Twitch: c2.ChannelType.Twitch, TwitchWhispers: c2.ChannelType.TwitchWhispers, TwitchWatching: c2.ChannelType.TwitchWatching, TwitchMentions: c2.ChannelType.TwitchMentions, TwitchLive: c2.ChannelType.TwitchLive, TwitchAutomod: c2.ChannelType.TwitchAutomod, TwitchEnd: c2.ChannelType.TwitchEnd, Misc: c2.ChannelType.Misc } c2.ChannelType = {} -- End src/common/Channel.hpp -- Begin src/controllers/plugins/api/ChannelRef.hpp ----@alias c2.Platform integer ---- This enum describes a platform for the purpose of searching for a channel. ---- Currently only Twitch is supported because identifying IRC channels is tricky. ----@type { Twitch: c2.Platform } -c2.Platform = {} +-- Begin src/providers/twitch/TwitchChannel.hpp + +---@class StreamStatus +---@field live boolean +---@field viewer_count number +---@field title string Stream title or last stream title +---@field game_name string +---@field game_id string +---@field uptime number Seconds since the stream started. + +---@class RoomModes +---@field subscriber_only boolean +---@field unique_chat boolean You might know this as r9kbeta or robot9000. +---@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 + +-- End src/providers/twitch/TwitchChannel.hpp ---@class c2.Channel c2.Channel = {} @@ -72,7 +102,7 @@ function c2.Channel:get_display_name() end --- Note that this does not execute client-commands. --- ---@param message string ----@param execute_commands boolean Should commands be run on the text? +---@param execute_commands? boolean Should commands be run on the text? function c2.Channel:send_message(message, execute_commands) end --- Adds a system message client-side @@ -131,9 +161,8 @@ function c2.Channel:__tostring() end --- - /automod --- ---@param name string Which channel are you looking for? ----@param platform c2.Platform Where to search for the channel? ---@return c2.Channel? -function c2.Channel.by_name(name, platform) end +function c2.Channel.by_name(name) end --- Finds a channel by the Twitch user ID of its owner. --- @@ -141,21 +170,6 @@ function c2.Channel.by_name(name, platform) end ---@return c2.Channel? function c2.Channel.by_twitch_id(id) 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 follower_only 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 - -- End src/controllers/plugins/api/ChannelRef.hpp -- Begin src/controllers/plugins/api/HTTPResponse.hpp @@ -176,6 +190,9 @@ function HTTPResponse:status() end --- function HTTPResponse:error() end +---@return string +function HTTPResponse:__tostring() end + -- End src/controllers/plugins/api/HTTPResponse.hpp -- Begin src/controllers/plugins/api/HTTPRequest.hpp @@ -219,6 +236,9 @@ function HTTPRequest:set_header(name, value) end --- function HTTPRequest:execute() end +---@return string +function HTTPRequest:__tostring() end + --- Creates a new HTTPRequest --- ---@param method HTTPMethod Method to use @@ -230,8 +250,13 @@ function HTTPRequest.create(method, url) end -- Begin src/common/network/NetworkCommon.hpp ----@alias HTTPMethod integer ----@type { Get: HTTPMethod, Post: HTTPMethod, Put: HTTPMethod, Delete: HTTPMethod, Patch: HTTPMethod } +---@alias HTTPMethod.Get "HTTPMethod.Get" +---@alias HTTPMethod.Post "HTTPMethod.Post" +---@alias HTTPMethod.Put "HTTPMethod.Put" +---@alias HTTPMethod.Delete "HTTPMethod.Delete" +---@alias HTTPMethod.Patch "HTTPMethod.Patch" +---@alias HTTPMethod HTTPMethod.Get|HTTPMethod.Post|HTTPMethod.Put|HTTPMethod.Delete|HTTPMethod.Patch +---@type { Get: HTTPMethod.Get, Post: HTTPMethod.Post, Put: HTTPMethod.Put, Delete: HTTPMethod.Delete, Patch: HTTPMethod.Patch } HTTPMethod = {} -- End src/common/network/NetworkCommon.hpp @@ -245,7 +270,7 @@ function c2.register_command(name, handler) end --- Registers a callback to be invoked when completions for a term are requested. --- ----@param type "CompletionRequested" +---@param type c2.EventType.CompletionRequested ---@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. function c2.register_callback(type, func) end diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index cd38fa18c..2fffb7429 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -171,7 +171,7 @@ function cmd_words(ctx) -- ctx contains: -- words - table of words supplied to the command including the trigger -- channel - the channel the command is being run in - channel:add_system_message("Words are: " .. table.concat(ctx.words, " ")) + ctx.channel:add_system_message("Words are: " .. table.concat(ctx.words, " ")) end c2.register_command("/words", cmd_words) @@ -183,7 +183,7 @@ Limitations/known issues: rebuilding the window content caused by reloading another plugin will solve this. - Spaces in command names aren't handled very well (https://github.com/Chatterino/chatterino2/issues/1517). -#### `register_callback("CompletionRequested", handler)` +#### `register_callback(c2.EventType.CompletionRequested, handler)` Registers a callback (`handler`) to process completions. The callback takes a single table with the following entries: @@ -207,7 +207,7 @@ function string.startswith(s, other) end c2.register_callback( - "CompletionRequested", + c2.EventType.CompletionRequested, function(event) if ("!join"):startswith(event.query) then ---@type CompletionList @@ -219,15 +219,6 @@ c2.register_callback( ) ``` -#### `Platform` enum - -This table describes platforms that can be accessed. Chatterino supports IRC -however plugins do not yet have explicit access to get IRC channels objects. -The values behind the names may change, do not count on them. It has the -following keys: - -- `Twitch` - #### `ChannelType` enum This table describes channel types Chatterino supports. The values behind the @@ -260,9 +251,9 @@ used on non-Twitch channels. Special channels while marked as is an actual Twitch chatroom use `Channel:get_type()` instead of `Channel:is_twitch_channel()`. -##### `Channel:by_name(name, platform)` +##### `Channel:by_name(name)` -Finds a channel given by `name` on `platform` (see [`Platform` enum](#Platform-enum)). Returns the channel or `nil` if not open. +Finds a channel given by `name`. Returns the channel or `nil` if not open. Some miscellaneous channels are marked as if they are specifically Twitch channels: @@ -275,7 +266,7 @@ Some miscellaneous channels are marked as if they are specifically Twitch channe Example: ```lua -local pajladas = c2.Channel.by_name("pajlada", c2.Platform.Twitch) +local pajladas = c2.Channel.by_name("pajlada") ``` ##### `Channel:by_twitch_id(id)` @@ -363,7 +354,7 @@ pajladas:add_system_message("Hello, world!") Returns `true` if the channel is a Twitch channel, that is its type name has the `Twitch` prefix. This returns `true` for special channels like Mentions. -You might want `Channel:get_type() == "Twitch"` if you want to use +You might want `Channel:get_type() == c2.ChannelType.Twitch` if you want to use Twitch-specific functions. ##### `Channel:get_twitch_id()` diff --git a/lib/lua/CMakeLists.txt b/lib/lua/CMakeLists.txt index cf2fad9bd..45824fdfa 100644 --- a/lib/lua/CMakeLists.txt +++ b/lib/lua/CMakeLists.txt @@ -1,48 +1,44 @@ project(lua CXX) #[====[ -Updating this list: -remove all listed files -go to line below, ^y2j4j$@" and then reindent the file names -/LUA_SRC -:r!ls lib/lua/src | grep '\.c' | grep -Ev 'lua\.c|onelua\.c' | sed 's#^#src/#' - +This list contains all .c files except lua.c and onelua.c +Use the following command from the repository root to get these file: +perl -e 'print s/^lib\/lua\///r . "\n" for grep { /\.c$/ && !/(lua|onelua)\.c$/ } glob "lib/lua/src/*.c"' #]====] set(LUA_SRC - "src/lapi.c" - "src/lauxlib.c" - "src/lbaselib.c" - "src/lcode.c" - "src/lcorolib.c" - "src/lctype.c" - "src/ldblib.c" - "src/ldebug.c" - "src/ldo.c" - "src/ldump.c" - "src/lfunc.c" - "src/lgc.c" - "src/linit.c" - "src/liolib.c" - "src/llex.c" - "src/lmathlib.c" - "src/lmem.c" - "src/loadlib.c" - "src/lobject.c" - "src/lopcodes.c" - "src/loslib.c" - "src/lparser.c" - "src/lstate.c" - "src/lstring.c" - "src/lstrlib.c" - "src/ltable.c" - "src/ltablib.c" - "src/ltests.c" - "src/ltm.c" - "src/lua.c" - "src/lundump.c" - "src/lutf8lib.c" - "src/lvm.c" - "src/lzio.c" + src/lapi.c + src/lauxlib.c + src/lbaselib.c + src/lcode.c + src/lcorolib.c + src/lctype.c + src/ldblib.c + src/ldebug.c + src/ldo.c + src/ldump.c + src/lfunc.c + src/lgc.c + src/linit.c + src/liolib.c + src/llex.c + src/lmathlib.c + src/lmem.c + src/loadlib.c + src/lobject.c + src/lopcodes.c + src/loslib.c + src/lparser.c + src/lstate.c + src/lstring.c + src/lstrlib.c + src/ltable.c + src/ltablib.c + src/ltests.c + src/ltm.c + src/lundump.c + src/lutf8lib.c + src/lvm.c + src/lzio.c ) add_library(lua STATIC ${LUA_SRC}) @@ -50,4 +46,14 @@ target_include_directories(lua PUBLIC ${LUA_INCLUDE_DIRS} ) -set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE C) +set_target_properties(${liblua} PROPERTIES + LANGUAGE CXX + LINKER_LANGUAGE CXX + CXX_STANDARD 98 + CXX_EXTENSIONS TRUE +) +target_compile_options(lua PRIVATE + -w # this makes clang shut up about c-as-c++ + $<$,$>:/EHsc> # enable exceptions in clang-cl +) +set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE CXX) diff --git a/lib/lua/src b/lib/lua/src index 0897c0a42..1ab3208a1 160000 --- a/lib/lua/src +++ b/lib/lua/src @@ -1 +1 @@ -Subproject commit 0897c0a4289ef3a8d45761266124613f364bef60 +Subproject commit 1ab3208a1fceb12fca8f24ba57d6e13c5bff15e3 diff --git a/lib/sol2 b/lib/sol2 new file mode 160000 index 000000000..2b0d2fe8b --- /dev/null +++ b/lib/sol2 @@ -0,0 +1 @@ +Subproject commit 2b0d2fe8ba0074e16b499940c4f3126b9c7d3471 diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index f9f44e7ed..b1420e780 100644 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -196,7 +196,7 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): if not comments[0].startswith("@"): out.write(f"--- {comments[0]}\n---\n") comments = comments[1:] - params = [] + params: list[str] = [] for comment in comments[:-1]: if not comment.startswith("@lua"): panic(path, line, f"Invalid function specification - got '{comment}'") @@ -209,7 +209,7 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): panic(path, line, f"Invalid function exposure - got '{comments[-1]}'") name = comments[-1].split(" ", 1)[1] printmsg(path, line, f"function {name}") - lua_params = ", ".join(params) + lua_params = ", ".join(p.removesuffix("?") for p in params) out.write(f"function {name}({lua_params}) end\n\n") @@ -242,13 +242,21 @@ def read_file(path: Path, out: TextIOWrapper): ) name = header[0].split(" ", 1)[1] printmsg(path, reader.line_no(), f"enum {name}") - out.write(f"---@alias {name} integer\n") + variants = reader.read_enum_variants() + + vtypes = [] + for variant in variants: + vtype = f'{name}.{variant}' + vtypes.append(vtype) + out.write(f'---@alias {vtype} "{vtype}"\n') + + out.write(f"---@alias {name} {'|'.join(vtypes)}\n") if header_comment: out.write(f"--- {header_comment}\n") out.write("---@type { ") out.write( ", ".join( - [f"{variant}: {name}" for variant in reader.read_enum_variants()] + [f"{variant}: {typ}" for variant, typ in zip(variants,vtypes)] ) ) out.write(" }\n") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4c24ef572..9d7134b97 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -225,24 +225,28 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/plugins/api/ChannelRef.cpp controllers/plugins/api/ChannelRef.hpp - controllers/plugins/api/IOWrapper.cpp - controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/EventType.hpp controllers/plugins/api/HTTPRequest.cpp controllers/plugins/api/HTTPRequest.hpp controllers/plugins/api/HTTPResponse.cpp controllers/plugins/api/HTTPResponse.hpp + controllers/plugins/api/IOWrapper.cpp + controllers/plugins/api/IOWrapper.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp - controllers/plugins/PluginPermission.cpp - controllers/plugins/PluginPermission.hpp - controllers/plugins/Plugin.cpp - controllers/plugins/Plugin.hpp - controllers/plugins/PluginController.hpp - controllers/plugins/PluginController.cpp controllers/plugins/LuaUtilities.cpp controllers/plugins/LuaUtilities.hpp + controllers/plugins/PluginController.cpp + controllers/plugins/PluginController.hpp + controllers/plugins/Plugin.cpp + controllers/plugins/Plugin.hpp + controllers/plugins/PluginPermission.cpp + controllers/plugins/PluginPermission.hpp + controllers/plugins/SolTypes.cpp + controllers/plugins/SolTypes.hpp controllers/sound/ISoundController.hpp controllers/sound/MiniaudioBackend.cpp @@ -791,7 +795,7 @@ target_link_libraries(${LIBRARY_PROJECT} $<$:Wtsapi32> ) if (CHATTERINO_PLUGINS) - target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua sol2::sol2) endif() if (BUILD_WITH_QTKEYCHAIN) diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index d7c289bca..a12265950 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -129,6 +129,10 @@ # include # include +# ifdef CHATTERINO_HAVE_PLUGINS +# include +# endif + # ifndef UNUSED # define UNUSED(x) (void)(x) # endif diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 554327622..ac90573ff 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -165,30 +165,3 @@ private: }; } // namespace chatterino - -template <> -constexpr magic_enum::customize::customize_t - magic_enum::customize::enum_name( - chatterino::Channel::Type value) noexcept -{ - using Type = chatterino::Channel::Type; - switch (value) - { - case Type::Twitch: - return "twitch"; - case Type::TwitchWhispers: - return "whispers"; - case Type::TwitchWatching: - return "watching"; - case Type::TwitchMentions: - return "mentions"; - case Type::TwitchLive: - return "live"; - case Type::TwitchAutomod: - return "automod"; - case Type::Misc: - return "misc"; - default: - return default_tag; - } -} diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 03d11750a..4f73c51ee 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -3,34 +3,45 @@ # include "Application.hpp" # include "common/QLogging.hpp" -# include "controllers/commands/CommandController.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginController.hpp" -# include "messages/MessageBuilder.hpp" -# include "providers/twitch/TwitchIrcServer.hpp" +# include "controllers/plugins/SolTypes.hpp" // for lua operations on QString{,List} for CompletionList -extern "C" { # include # include # include -} # include +# include # include # include # include +# include +# include +# include +# include +# include +# include +# include +# include + +# include +# include +# include namespace { using namespace chatterino; -void logHelper(lua_State *L, Plugin *pl, QDebug stream, int argc) +void logHelper(lua_State *L, Plugin *pl, QDebug stream, + const sol::variadic_args &args) { stream.noquote(); stream << "[" + pl->id + ":" + pl->meta.name + "]"; - for (int i = 1; i <= argc; i++) + for (const auto &arg : args) { - stream << lua::toString(L, i); + stream << lua::toString(L, arg.stack_index()); + // Remove this from our stack + lua_pop(L, 1); } - lua_pop(L, argc); } QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) @@ -63,195 +74,92 @@ QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) // luaL_error is a c-style vararg function, this makes clang-tidy not dislike it so much namespace chatterino::lua::api { -int c2_register_command(lua_State *L) +CompletionList::CompletionList(const sol::table &table) + : values(table.get("values")) + , hideOthers(table["hide_others"]) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "internal error: no plugin"); - return 0; - } - - QString name; - if (!lua::peek(L, &name, 1)) - { - luaL_error(L, "cannot get command name (1st arg of register_command, " - "expected a string)"); - return 0; - } - if (lua_isnoneornil(L, 2)) - { - luaL_error(L, "missing argument for register_command: function " - "\"pointer\""); - return 0; - } - - auto callbackSavedName = QString("c2commandcb-%1").arg(name); - lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str()); - auto ok = pl->registerCommand(name, callbackSavedName); - - // delete both name and callback - lua_pop(L, 2); - - lua::push(L, ok); - return 1; } -int c2_register_callback(lua_State *L) +sol::table toTable(lua_State *L, const CompletionEvent &ev) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "internal error: no plugin"); - return 0; - } - EventType evtType{}; - if (!lua::peek(L, &evtType, 1)) - { - luaL_error(L, "cannot get event name (1st arg of register_callback, " - "expected a string)"); - return 0; - } - if (lua_isnoneornil(L, 2)) - { - luaL_error(L, "missing argument for register_callback: function " - "\"pointer\""); - return 0; - } - - auto typeName = magic_enum::enum_name(evtType); - std::string callbackSavedName; - callbackSavedName.reserve(5 + typeName.size()); - callbackSavedName += "c2cb-"; - callbackSavedName += typeName; - lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.c_str()); - - lua_pop(L, 2); - - return 0; + return sol::state_view(L).create_table_with( + "query", ev.query, // + "full_text_content", ev.full_text_content, // + "cursor_position", ev.cursor_position, // + "is_first_word", ev.is_first_word // + ); } -int c2_log(lua_State *L) +void c2_register_callback(ThisPluginState L, EventType evtType, + sol::protected_function callback) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "c2_log: internal error: no plugin?"); - return 0; - } - auto logc = lua_gettop(L) - 1; - // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop - LogLevel lvl{}; - if (!lua::pop(L, &lvl, 1)) - { - luaL_error(L, "Invalid log level, use one from c2.LogLevel."); - return 0; - } - QDebug stream = qdebugStreamForLogLevel(lvl); - logHelper(L, pl, stream, logc); - return 0; + L.plugin()->callbacks[evtType] = std::move(callback); } -int c2_later(lua_State *L) +void c2_log(ThisPluginState L, LogLevel lvl, sol::variadic_args args) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) + lua::StackGuard guard(L); { - return luaL_error(L, "c2.later: internal error: no plugin?"); - } - if (lua_gettop(L) != 2) - { - return luaL_error( - L, "c2.later expects two arguments (a callback that takes no " - "arguments and returns nothing and a number the time in " - "milliseconds to wait)\n"); - } - int time{}; - if (!lua::pop(L, &time)) - { - return luaL_error(L, "cannot get time (2nd arg of c2.later, " - "expected a number)"); + QDebug stream = qdebugStreamForLogLevel(lvl); + logHelper(L, L.plugin(), stream, args); } +} - if (!lua_isfunction(L, lua_gettop(L))) +void c2_later(ThisPluginState L, sol::protected_function callback, int time) +{ + if (time <= 0) { - return luaL_error(L, "cannot get callback (1st arg of c2.later, " - "expected a function)"); + throw std::runtime_error( + "c2.later time must be strictly greater than zero."); } + sol::state_view lua(L); auto *timer = new QTimer(); timer->setInterval(time); - auto id = pl->addTimeout(timer); + timer->setSingleShot(true); + auto id = L.plugin()->addTimeout(timer); auto name = QString("timeout_%1").arg(id); - auto *coro = lua_newthread(L); - QObject::connect(timer, &QTimer::timeout, [pl, coro, name, timer]() { - timer->deleteLater(); - pl->removeTimeout(timer); - int nres{}; - lua_resume(coro, nullptr, 0, &nres); + sol::state_view main = sol::main_thread(L); - lua_pushnil(coro); - lua_setfield(coro, LUA_REGISTRYINDEX, name.toStdString().c_str()); - if (lua_gettop(coro) != 0) - { - stackDump(coro, - pl->id + - ": timer returned a value, this shouldn't happen " - "and is probably a plugin bug"); - } - }); - stackDump(L, "before setfield"); - lua_setfield(L, LUA_REGISTRYINDEX, name.toStdString().c_str()); - lua_xmove(L, coro, 1); // move function to thread + sol::thread thread = sol::thread::create(main); + sol::protected_function cb(thread.state(), callback); + main.registry()[name.toStdString()] = thread; + + QObject::connect( + timer, &QTimer::timeout, + [pl = L.plugin(), name, timer, cb, thread, main]() { + timer->deleteLater(); + pl->removeTimeout(timer); + sol::protected_function_result res = cb(); + + if (res.return_count() != 0) + { + stackDump(thread.lua_state(), + pl->id + + ": timer returned a value, this shouldn't happen " + "and is probably a plugin bug"); + } + main.registry()[name.toStdString()] = sol::nil; + }); timer->start(); - - return 0; } -int g_load(lua_State *L) +// TODO: Add tests for this once we run tests in debug mode +sol::variadic_results g_load(ThisPluginState s, sol::object data) { # ifdef NDEBUG - luaL_error(L, "load() is only usable in debug mode"); - return 0; + (void)data; + (void)s; + throw std::runtime_error("load() is only usable in debug mode"); # else - auto countArgs = lua_gettop(L); - QByteArray data; - if (lua::peek(L, &data, 1)) - { - auto *utf8 = QTextCodec::codecForName("UTF-8"); - QTextCodec::ConverterState state; - utf8->toUnicode(data.constData(), data.size(), &state); - if (state.invalidChars != 0) - { - luaL_error(L, "invalid utf-8 in load() is not allowed"); - return 0; - } - } - else - { - luaL_error(L, "using reader function in load() is not allowed"); - return 0; - } - for (int i = 0; i < countArgs; i++) - { - lua_seti(L, LUA_REGISTRYINDEX, i); - } - - // fetch load and call it - lua_getfield(L, LUA_REGISTRYINDEX, "real_load"); - - for (int i = 0; i < countArgs; i++) - { - lua_geti(L, LUA_REGISTRYINDEX, i); - lua_pushnil(L); - lua_seti(L, LUA_REGISTRYINDEX, i); - } - - lua_call(L, countArgs, LUA_MULTRET); - - return lua_gettop(L); + // If you're modifying this PLEASE verify it works, Sol is very annoying about serialization + // - Mm2PL + sol::state_view lua(s); + auto load = lua.registry()["real_load"]; + sol::protected_function_result ret = load(data, "=(load)", "t"); + return ret; # endif } @@ -320,7 +228,7 @@ int searcherAbsolute(lua_State *L) int searcherRelative(lua_State *L) { lua_Debug dbg; - lua_getstack(L, 1, &dbg); + lua_getstack(L, 2, &dbg); lua_getinfo(L, "S", &dbg); auto currentFile = QString::fromUtf8(dbg.source, dbg.srclen); if (currentFile.startsWith("@")) @@ -346,22 +254,14 @@ int searcherRelative(lua_State *L) return loadfile(L, filename); } -int g_print(lua_State *L) +void g_print(ThisPluginState L, sol::variadic_args args) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "c2_print: internal error: no plugin?"); - return 0; - } - auto argc = lua_gettop(L); // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop auto stream = (QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, QT_MESSAGELOG_FUNC, chatterinoLua().categoryName()) .debug()); - logHelper(L, pl, stream, argc); - return 0; + logHelper(L, L.plugin(), stream, args); } } // namespace chatterino::lua::api diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index 904c6daa4..bd83dee5a 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -1,17 +1,17 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/SolTypes.hpp" -extern "C" { # include -} -# include "controllers/plugins/LuaUtilities.hpp" - +# include # include +# include # include # include -# include struct lua_State; namespace chatterino::lua::api { @@ -30,11 +30,8 @@ namespace chatterino::lua::api { enum class LogLevel { Debug, Info, Warning, Critical }; /** - * @exposeenum c2.EventType + * @includefile controllers/plugins/api/EventType.hpp */ -enum class EventType { - CompletionRequested, -}; /** * @lua@class CommandContext @@ -46,10 +43,12 @@ enum class EventType { * @lua@class CompletionList */ struct CompletionList { + CompletionList(const sol::table &); + /** * @lua@field values string[] The completions */ - std::vector values{}; + QStringList values; /** * @lua@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. @@ -79,6 +78,8 @@ struct CompletionEvent { bool is_first_word{}; }; +sol::table toTable(lua_State *L, const CompletionEvent &ev); + /** * @includefile common/Channel.hpp * @includefile controllers/plugins/api/ChannelRef.hpp @@ -95,16 +96,16 @@ struct CompletionEvent { * @lua@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. * @exposed c2.register_command */ -int c2_register_command(lua_State *L); /** * Registers a callback to be invoked when completions for a term are requested. * - * @lua@param type "CompletionRequested" + * @lua@param type c2.EventType.CompletionRequested * @lua@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. * @exposed c2.register_callback */ -int c2_register_callback(lua_State *L); +void c2_register_callback(ThisPluginState L, EventType evtType, + sol::protected_function callback); /** * Writes a message to the Chatterino log. @@ -113,7 +114,7 @@ int c2_register_callback(lua_State *L); * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. * @exposed c2.log */ -int c2_log(lua_State *L); +void c2_log(ThisPluginState L, LogLevel lvl, sol::variadic_args args); /** * Calls callback around msec milliseconds later. Does not freeze Chatterino. @@ -122,11 +123,11 @@ int c2_log(lua_State *L); * @lua@param msec number How long to wait. * @exposed c2.later */ -int c2_later(lua_State *L); +void c2_later(ThisPluginState L, sol::protected_function callback, int time); // These ones are global -int g_load(lua_State *L); -int g_print(lua_State *L); +sol::variadic_results g_load(ThisPluginState s, sol::object data); +void g_print(ThisPluginState L, sol::variadic_args args); // NOLINTEND(readability-identifier-naming) // This is for require() exposed as an element of package.searchers diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp index 64af18c01..58f648f0a 100644 --- a/src/controllers/plugins/LuaUtilities.cpp +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -1,16 +1,10 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/LuaUtilities.hpp" -# include "common/Channel.hpp" # include "common/QLogging.hpp" -# include "controllers/commands/CommandContext.hpp" -# include "controllers/plugins/api/ChannelRef.hpp" -# include "controllers/plugins/LuaAPI.hpp" -extern "C" { # include # include -} # include # include @@ -79,9 +73,6 @@ QString humanErrorText(lua_State *L, int errCode) case LUA_ERRFILE: errName = "(file error)"; break; - case ERROR_BAD_PEEK: - errName = "(unable to convert value to c++)"; - break; default: errName = "(unknown error type)"; } @@ -93,18 +84,6 @@ QString humanErrorText(lua_State *L, int errCode) return errName; } -StackIdx pushEmptyArray(lua_State *L, int countArray) -{ - lua_createtable(L, countArray, 0); - return lua_gettop(L); -} - -StackIdx pushEmptyTable(lua_State *L, int countProperties) -{ - lua_createtable(L, 0, countProperties); - return lua_gettop(L); -} - StackIdx push(lua_State *L, const QString &str) { return lua::push(L, str.toStdString()); @@ -116,82 +95,6 @@ StackIdx push(lua_State *L, const std::string &str) return lua_gettop(L); } -StackIdx push(lua_State *L, const CommandContext &ctx) -{ - StackGuard guard(L, 1); - auto outIdx = pushEmptyTable(L, 2); - - push(L, ctx.words); - lua_setfield(L, outIdx, "words"); - - push(L, ctx.channel); - lua_setfield(L, outIdx, "channel"); - - return outIdx; -} - -StackIdx push(lua_State *L, const bool &b) -{ - lua_pushboolean(L, int(b)); - return lua_gettop(L); -} - -StackIdx push(lua_State *L, const int &b) -{ - lua_pushinteger(L, b); - return lua_gettop(L); -} - -StackIdx push(lua_State *L, const api::CompletionEvent &ev) -{ - auto idx = pushEmptyTable(L, 4); -# define PUSH(field) \ - lua::push(L, ev.field); \ - lua_setfield(L, idx, #field) - PUSH(query); - PUSH(full_text_content); - PUSH(cursor_position); - PUSH(is_first_word); -# undef PUSH - return idx; -} - -bool peek(lua_State *L, int *out, StackIdx idx) -{ - StackGuard guard(L); - if (lua_isnumber(L, idx) == 0) - { - return false; - } - - *out = lua_tointeger(L, idx); - return true; -} - -bool peek(lua_State *L, bool *out, StackIdx idx) -{ - StackGuard guard(L); - if (!lua_isboolean(L, idx)) - { - return false; - } - - *out = bool(lua_toboolean(L, idx)); - return true; -} - -bool peek(lua_State *L, double *out, StackIdx idx) -{ - StackGuard guard(L); - int ok{0}; - auto v = lua_tonumberx(L, idx, &ok); - if (ok != 0) - { - *out = v; - } - return ok != 0; -} - bool peek(lua_State *L, QString *out, StackIdx idx) { StackGuard guard(L); @@ -209,57 +112,6 @@ bool peek(lua_State *L, QString *out, StackIdx idx) return true; } -bool peek(lua_State *L, QByteArray *out, StackIdx idx) -{ - StackGuard guard(L); - size_t len{0}; - const char *str = lua_tolstring(L, idx, &len); - if (str == nullptr) - { - return false; - } - if (len >= INT_MAX) - { - assert(false && "string longer than INT_MAX, shit's fucked, yo"); - } - *out = QByteArray(str, int(len)); - return true; -} - -bool peek(lua_State *L, std::string *out, StackIdx idx) -{ - StackGuard guard(L); - size_t len{0}; - const char *str = lua_tolstring(L, idx, &len); - if (str == nullptr) - { - return false; - } - if (len >= INT_MAX) - { - assert(false && "string longer than INT_MAX, shit's fucked, yo"); - } - *out = std::string(str, len); - return true; -} - -bool peek(lua_State *L, api::CompletionList *out, StackIdx idx) -{ - StackGuard guard(L); - int typ = lua_getfield(L, idx, "values"); - if (typ != LUA_TTABLE) - { - lua_pop(L, 1); - return false; - } - if (!lua::pop(L, &out->values, -1)) - { - return false; - } - lua_getfield(L, idx, "hide_others"); - return lua::pop(L, &out->hideOthers); -} - QString toString(lua_State *L, StackIdx idx) { size_t len{}; diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 5443a751f..0f7bdc53f 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -2,37 +2,20 @@ #ifdef CHATTERINO_HAVE_PLUGINS -# include "common/QLogging.hpp" - -extern "C" { # include # include -} # include # include +# include # include -# include # include # include # include -# include -# include struct lua_State; -class QJsonObject; -namespace chatterino { -struct CommandContext; -} // namespace chatterino namespace chatterino::lua { -namespace api { - struct CompletionList; - struct CompletionEvent; -} // namespace api - -constexpr int ERROR_BAD_PEEK = LUA_OK - 1; - /** * @brief Dumps the Lua stack into qCDebug(chatterinoLua) * @@ -40,6 +23,9 @@ constexpr int ERROR_BAD_PEEK = LUA_OK - 1; */ void stackDump(lua_State *L, const QString &tag); +// This is for calling stackDump out of gdb as it's not easy to create a QString there +const QString GDB_DUMMY = "GDB_DUMMY"; + /** * @brief Converts a lua error code and potentially string on top of the stack into a human readable message */ @@ -50,33 +36,11 @@ QString humanErrorText(lua_State *L, int errCode); */ using StackIdx = int; -/** - * @brief Creates a table with countArray array properties on the Lua stack - * @return stack index of the newly created table - */ -StackIdx pushEmptyArray(lua_State *L, int countArray); - -/** - * @brief Creates a table with countProperties named properties on the Lua stack - * @return stack index of the newly created table - */ -StackIdx pushEmptyTable(lua_State *L, int countProperties); - -StackIdx push(lua_State *L, const CommandContext &ctx); StackIdx push(lua_State *L, const QString &str); StackIdx push(lua_State *L, const std::string &str); -StackIdx push(lua_State *L, const bool &b); -StackIdx push(lua_State *L, const int &b); -StackIdx push(lua_State *L, const api::CompletionEvent &ev); // returns OK? -bool peek(lua_State *L, int *out, StackIdx idx = -1); -bool peek(lua_State *L, bool *out, StackIdx idx = -1); -bool peek(lua_State *L, double *out, StackIdx idx = -1); bool peek(lua_State *L, QString *out, StackIdx idx = -1); -bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1); -bool peek(lua_State *L, std::string *out, StackIdx idx = -1); -bool peek(lua_State *L, api::CompletionList *out, StackIdx idx = -1); /** * @brief Converts Lua object at stack index idx to a string. @@ -140,246 +104,29 @@ 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) -{ - if (lua_isnil(L, idx)) - { - *out = std::nullopt; - return true; - } - - *out = T(); - return peek(L, out->operator->(), idx); -} - -template -bool peek(lua_State *L, std::vector *vec, StackIdx idx = -1) -{ - StackGuard guard(L); - - if (!lua_istable(L, idx)) - { - lua::stackDump(L, "!table"); - qCDebug(chatterinoLua) - << "value is not a table, type is" << lua_type(L, idx); - return false; - } - auto len = lua_rawlen(L, idx); - if (len == 0) - { - qCDebug(chatterinoLua) << "value has 0 length"; - return true; - } - if (len > 1'000'000) - { - qCDebug(chatterinoLua) << "value is too long"; - return false; - } - // count like lua - for (int i = 1; i <= len; i++) - { - lua_geti(L, idx, i); - std::optional obj; - if (!lua::peek(L, &obj)) - { - //lua_seti(L, LUA_REGISTRYINDEX, 1); // lazy - qCDebug(chatterinoLua) - << "Failed to convert lua object into c++: at array index " << i - << ":"; - stackDump(L, "bad conversion into string"); - return false; - } - lua_pop(L, 1); - vec->push_back(obj.value()); - } - return true; -} - -/** - * @brief Converts object at stack index idx to enum given by template parameter T - */ -template , bool>::type = true> -bool peek(lua_State *L, T *out, StackIdx idx = -1) -{ - std::string tmp; - if (!lua::peek(L, &tmp, idx)) - { - return false; - } - std::optional opt = magic_enum::enum_cast(tmp); - if (opt.has_value()) - { - *out = opt.value(); - return true; - } - - return false; -} - -/** - * @brief Converts a vector to Lua and pushes it onto the stack. - * - * Needs StackIdx push(lua_State*, T); to work. - * - * @return Stack index of newly created table. - */ -template -StackIdx push(lua_State *L, std::vector vec) -{ - auto out = pushEmptyArray(L, vec.size()); - int i = 1; - for (const auto &el : vec) - { - push(L, el); - lua_seti(L, out, i); - i += 1; - } - return out; -} - -/** - * @brief Converts a QList to Lua and pushes it onto the stack. - * - * Needs StackIdx push(lua_State*, T); to work. - * - * @return Stack index of newly created table. - */ -template -StackIdx push(lua_State *L, QList vec) -{ - auto out = pushEmptyArray(L, vec.size()); - int i = 1; - for (const auto &el : vec) - { - push(L, el); - lua_seti(L, out, i); - i += 1; - } - return out; -} - -/** - * @brief Converts an enum given by T to Lua (into a string) and pushes it onto the stack. - * - * @return Stack index of newly created string. - */ -template , bool> = true> -StackIdx push(lua_State *L, T inp) -{ - std::string_view name = magic_enum::enum_name(inp); - return lua::push(L, std::string(name)); -} - -/** - * @brief Converts a Lua object into c++ and removes it from the stack. - * If peek fails, the object is still removed from the stack. - * - * Relies on bool peek(lua_State*, T*, StackIdx) existing. - */ -template -bool pop(lua_State *L, T *out, StackIdx idx = -1) -{ - StackGuard guard(L, -1); - auto ok = peek(L, out, idx); - if (idx < 0) - { - idx = lua_gettop(L) + idx + 1; - } - lua_remove(L, idx); - return ok; -} - /** * @brief Creates a table mapping enum names to unique values. * * Values in this table may change. * - * @returns stack index of newly created table + * @returns Sol reference to the table */ template -StackIdx pushEnumTable(lua_State *L) + requires std::is_enum_v +sol::table createEnumTable(sol::state_view &lua) { - // std::array - auto values = magic_enum::enum_values(); - StackIdx out = lua::pushEmptyTable(L, values.size()); + constexpr auto values = magic_enum::enum_values(); + auto out = lua.create_table(0, values.size()); for (const T v : values) { std::string_view name = magic_enum::enum_name(v); std::string str(name); - lua::push(L, str); - lua_setfield(L, out, str.c_str()); + out.raw_set(str, v); } return out; } -// Represents a Lua function on the stack -template -class CallbackFunction -{ - StackIdx stackIdx_; - lua_State *L; - -public: - CallbackFunction(lua_State *L, StackIdx stackIdx) - : stackIdx_(stackIdx) - , L(L) - { - } - - // this type owns the stackidx, it must not be trivially copiable - CallbackFunction operator=(CallbackFunction &) = delete; - CallbackFunction(CallbackFunction &) = delete; - - // Permit only move - CallbackFunction &operator=(CallbackFunction &&) = default; - CallbackFunction(CallbackFunction &&) = default; - - ~CallbackFunction() - { - lua_remove(L, this->stackIdx_); - } - - std::variant operator()(Args... arguments) - { - lua_pushvalue(this->L, this->stackIdx_); - ( // apparently this calls lua::push() for every Arg - [this, &arguments] { - lua::push(this->L, arguments); - }(), - ...); - - int res = lua_pcall(L, sizeof...(Args), 1, 0); - if (res != LUA_OK) - { - qCDebug(chatterinoLua) << "error is: " << res; - return {res}; - } - - ReturnType val; - if (!lua::pop(L, &val)) - { - return {ERROR_BAD_PEEK}; - } - return {val}; - } -}; - } // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index dcf7357e8..739fa6372 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -7,14 +7,13 @@ # include "controllers/plugins/PluginPermission.hpp" # include "util/QMagicEnum.hpp" -extern "C" { # include -} # include # include # include # include # include +# include # include # include @@ -190,7 +189,8 @@ PluginMeta::PluginMeta(const QJsonObject &obj) } } -bool Plugin::registerCommand(const QString &name, const QString &functionName) +bool Plugin::registerCommand(const QString &name, + sol::protected_function function) { if (this->ownedCommands.find(name) != this->ownedCommands.end()) { @@ -202,7 +202,7 @@ bool Plugin::registerCommand(const QString &name, const QString &functionName) { return false; } - this->ownedCommands.insert({name, functionName}); + this->ownedCommands.emplace(name, std::move(function)); return true; } @@ -223,14 +223,24 @@ Plugin::~Plugin() QObject::disconnect(timer, nullptr, nullptr, nullptr); timer->deleteLater(); } + this->httpRequests.clear(); qCDebug(chatterinoLua) << "Destroyed" << this->activeTimeouts.size() << "timers for plugin" << this->id << "while destroying the object"; this->activeTimeouts.clear(); if (this->state_ != nullptr) { + // clearing this after the state is gone is not safe to do + this->ownedCommands.clear(); + this->callbacks.clear(); lua_close(this->state_); } + assert(this->ownedCommands.empty() && + "This must be empty or destructor of sol::protected_function would " + "explode malloc structures later"); + assert(this->callbacks.empty() && + "This must be empty or destructor of sol::protected_function would " + "explode malloc structures later"); } int Plugin::addTimeout(QTimer *timer) { diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index f8375247f..a65329468 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,8 +2,8 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" -# include "common/network/NetworkCommon.hpp" -# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/api/EventType.hpp" +# include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginPermission.hpp" @@ -11,7 +11,10 @@ # include # include # include +# include +# include +# include # include # include # include @@ -56,6 +59,8 @@ struct PluginMeta { } explicit PluginMeta(const QJsonObject &obj); + // This is for tests + PluginMeta() = default; }; class Plugin @@ -75,13 +80,18 @@ public: ~Plugin(); + Plugin(const Plugin &) = delete; + Plugin(Plugin &&) = delete; + Plugin &operator=(const Plugin &) = delete; + Plugin &operator=(Plugin &&) = delete; + /** * @brief Perform all necessary tasks to bind a command name to this plugin * @param name name of the command to create - * @param functionName name of the function that should be called when the command is executed + * @param function the function that should be called when the command is executed * @return true if addition succeeded, false otherwise (for example because the command name is already taken) */ - bool registerCommand(const QString &name, const QString &functionName); + bool registerCommand(const QString &name, sol::protected_function function); /** * @brief Get names of all commands belonging to this plugin @@ -98,35 +108,19 @@ public: return this->loadDirectory_.absoluteFilePath("data"); } - // Note: The CallbackFunction object's destructor will remove the function from the lua stack - using LuaCompletionCallback = - lua::CallbackFunction; - std::optional getCompletionCallback() + std::optional getCompletionCallback() { if (this->state_ == nullptr || !this->error_.isNull()) { return {}; } - // this uses magic enum to help automatic tooling find usages - auto typeName = - magic_enum::enum_name(lua::api::EventType::CompletionRequested); - std::string cbName; - cbName.reserve(5 + typeName.size()); - cbName += "c2cb-"; - cbName += typeName; - auto typ = - lua_getfield(this->state_, LUA_REGISTRYINDEX, cbName.c_str()); - if (typ != LUA_TFUNCTION) + auto it = + this->callbacks.find(lua::api::EventType::CompletionRequested); + if (it == this->callbacks.end()) { - lua_pop(this->state_, 1); return {}; } - - // move - return std::make_optional>( - this->state_, lua_gettop(this->state_)); + return it->second; } /** @@ -143,18 +137,25 @@ public: bool hasFSPermissionFor(bool write, const QString &path); bool hasHTTPPermissionFor(const QUrl &url); + std::map callbacks; + + // In-flight HTTP Requests + // This is a lifetime hack to ensure they get deleted with the plugin. This relies on the Plugin getting deleted on reload! + std::vector> httpRequests; + private: QDir loadDirectory_; lua_State *state_; QString error_; - // maps command name -> function name - std::unordered_map ownedCommands; + // maps command name -> function + std::unordered_map ownedCommands; std::vector activeTimeouts; int lastTimerId = 0; friend class PluginController; + friend class PluginControllerAccess; // this is for tests }; } // namespace chatterino #endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 5ca77ed40..1a2bc3a10 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -13,16 +13,20 @@ # include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "messages/MessageBuilder.hpp" # include "singletons/Paths.hpp" # include "singletons/Settings.hpp" -extern "C" { # include # include # include -} # include +# include +# include +# include +# include +# include # include # include @@ -113,10 +117,11 @@ bool PluginController::tryLoadFromDir(const QDir &pluginDir) return true; } -void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, - const QDir &pluginDir) +void PluginController::openLibrariesFor(Plugin *plugin) { + auto *L = plugin->state_; lua::StackGuard guard(L); + sol::state_view lua(L); // Stuff to change, remove or hide behind a permission system: static const std::vector loadedlibs = { luaL_Reg{LUA_GNAME, luaopen_base}, @@ -124,8 +129,6 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, luaL_Reg{LUA_COLIBNAME, luaopen_coroutine}, luaL_Reg{LUA_TABLIBNAME, luaopen_table}, - // luaL_Reg{LUA_IOLIBNAME, luaopen_io}, - // - explicit fs access, needs wrapper with permissions, no usage ideas yet // luaL_Reg{LUA_OSLIBNAME, luaopen_os}, // - fs access // - environ access @@ -147,155 +150,100 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, luaL_requiref(L, LUA_IOLIBNAME, luaopen_io, int(false)); lua_setfield(L, LUA_REGISTRYINDEX, lua::api::REG_REAL_IO_NAME); - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg c2Lib[] = { - {"register_command", lua::api::c2_register_command}, - {"register_callback", lua::api::c2_register_callback}, - {"log", lua::api::c2_log}, - {"later", lua::api::c2_later}, - {nullptr, nullptr}, - }; - lua_pushglobaltable(L); - auto gtable = lua_gettop(L); - - // count of elements in C2LIB + LogLevel + EventType - auto c2libIdx = lua::pushEmptyTable(L, 8); - - luaL_setfuncs(L, c2Lib, 0); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "LogLevel"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "EventType"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "Platform"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "ChannelType"); - - lua::pushEnumTable(L); - lua_setfield(L, c2libIdx, "HTTPMethod"); - - // Initialize metatables for objects - lua::api::ChannelRef::createMetatable(L); - lua_setfield(L, c2libIdx, "Channel"); - - lua::api::HTTPRequest::createMetatable(L); - lua_setfield(L, c2libIdx, "HTTPRequest"); - - lua::api::HTTPResponse::createMetatable(L); - lua_setfield(L, c2libIdx, "HTTPResponse"); - - lua_setfield(L, gtable, "c2"); + auto r = lua.registry(); + auto g = lua.globals(); + auto c2 = lua.create_table(); + g["c2"] = c2; // ban functions // Note: this might not be fully secure? some kind of metatable fuckery might come up? - // possibly randomize this name at runtime to prevent some attacks? - # ifndef NDEBUG - lua_getfield(L, gtable, "load"); - lua_setfield(L, LUA_REGISTRYINDEX, "real_load"); + lua.registry()["real_load"] = lua.globals()["load"]; # endif + // See chatterino::lua::api::g_load implementation - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg replacementFuncs[] = { - {"load", lua::api::g_load}, - {"print", lua::api::g_print}, - {nullptr, nullptr}, - }; - luaL_setfuncs(L, replacementFuncs, 0); - - lua_pushnil(L); - lua_setfield(L, gtable, "loadfile"); - - lua_pushnil(L); - lua_setfield(L, gtable, "dofile"); + g["loadfile"] = sol::nil; + g["dofile"] = sol::nil; // set up package lib - lua_getfield(L, gtable, "package"); - - auto package = lua_gettop(L); - lua_pushstring(L, ""); - lua_setfield(L, package, "cpath"); - - // we don't use path - lua_pushstring(L, ""); - lua_setfield(L, package, "path"); - { - lua_getfield(L, gtable, "table"); - auto table = lua_gettop(L); - lua_getfield(L, -1, "remove"); - lua_remove(L, table); - } - auto remove = lua_gettop(L); + auto package = g["package"]; + package["cpath"] = ""; + package["path"] = ""; - // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload - for (int i = 0; i < 3; i++) + sol::protected_function tbremove = g["table"]["remove"]; + + // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload + sol::table searchers = package["searchers"]; + for (int i = 0; i < 3; i++) + { + tbremove(searchers); + } + searchers.add(&lua::api::searcherRelative); + searchers.add(&lua::api::searcherAbsolute); + } + // set up io lib { - lua_pushvalue(L, remove); - lua_getfield(L, package, "searchers"); - lua_pcall(L, 1, 0, 0); + auto c2io = lua.create_table(); + auto realio = r[lua::api::REG_REAL_IO_NAME]; + c2io["type"] = realio["type"]; + g["io"] = c2io; + // prevent plugins getting direct access to realio + r[LUA_LOADED_TABLE]["io"] = c2io; + + // Don't give plugins the option to shit into our stdio + r["_IO_input"] = sol::nil; + r["_IO_output"] = sol::nil; } - lua_pop(L, 1); // get rid of remove + PluginController::initSol(lua, plugin); +} - lua_getfield(L, package, "searchers"); - lua_pushcclosure(L, lua::api::searcherRelative, 0); - lua_seti(L, -2, 2); +// TODO: investigate if `plugin` can ever point to an invalid plugin, +// especially in cases when the plugin is errored. +void PluginController::initSol(sol::state_view &lua, Plugin *plugin) +{ + auto g = lua.globals(); + // Do not capture plugin->state_ in lambdas, this makes the functions unusable in callbacks + g.set_function("print", &lua::api::g_print); + g.set_function("load", &lua::api::g_load); - lua::push(L, QString(pluginDir.absolutePath())); - lua_pushcclosure(L, lua::api::searcherAbsolute, 1); - lua_seti(L, -2, 3); - lua_pop(L, 2); // remove package, package.searchers + sol::table c2 = g["c2"]; + c2.set_function("register_command", + [plugin](const QString &name, sol::protected_function cb) { + return plugin->registerCommand(name, std::move(cb)); + }); + c2.set_function("register_callback", &lua::api::c2_register_callback); + c2.set_function("log", &lua::api::c2_log); + c2.set_function("later", &lua::api::c2_later); - // NOLINTNEXTLINE(*-avoid-c-arrays) - static const luaL_Reg ioLib[] = { - {"close", lua::api::io_close}, - {"flush", lua::api::io_flush}, - {"input", lua::api::io_input}, - {"lines", lua::api::io_lines}, - {"open", lua::api::io_open}, - {"output", lua::api::io_output}, - {"popen", lua::api::io_popen}, // stub - {"read", lua::api::io_read}, - {"tmpfile", lua::api::io_tmpfile}, // stub - {"write", lua::api::io_write}, - // type = realio.type - {nullptr, nullptr}, - }; - // TODO: io.popen stub - auto iolibIdx = lua::pushEmptyTable(L, 1); - luaL_setfuncs(L, ioLib, 0); + lua::api::ChannelRef::createUserType(c2); + lua::api::HTTPResponse::createUserType(c2); + lua::api::HTTPRequest::createUserType(c2); + c2["ChannelType"] = lua::createEnumTable(lua); + c2["HTTPMethod"] = lua::createEnumTable(lua); + c2["EventType"] = lua::createEnumTable(lua); + c2["LogLevel"] = lua::createEnumTable(lua); - // set ourio.type = realio.type - lua_pushvalue(L, iolibIdx); - lua_getfield(L, LUA_REGISTRYINDEX, lua::api::REG_REAL_IO_NAME); - lua_getfield(L, -1, "type"); - lua_remove(L, -2); // remove realio - lua_setfield(L, iolibIdx, "type"); - lua_pop(L, 1); // still have iolib on top of stack - - lua_pushvalue(L, iolibIdx); - lua_setfield(L, gtable, "io"); - - lua_pushvalue(L, iolibIdx); - lua_setfield(L, LUA_REGISTRYINDEX, lua::api::REG_C2_IO_NAME); - - luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE); - lua_pushvalue(L, iolibIdx); - lua_setfield(L, -2, "io"); - - lua_pop(L, 3); // remove gtable, iolib, LOADED - - // Don't give plugins the option to shit into our stdio - lua_pushnil(L); - lua_setfield(L, LUA_REGISTRYINDEX, "_IO_input"); - - lua_pushnil(L); - lua_setfield(L, LUA_REGISTRYINDEX, "_IO_output"); + sol::table io = g["io"]; + io.set_function( + "open", sol::overload(&lua::api::io_open, &lua::api::io_open_modeless)); + io.set_function("lines", sol::overload(&lua::api::io_lines, + &lua::api::io_lines_noargs)); + io.set_function("input", sol::overload(&lua::api::io_input_argless, + &lua::api::io_input_name, + &lua::api::io_input_file)); + io.set_function("output", sol::overload(&lua::api::io_output_argless, + &lua::api::io_output_name, + &lua::api::io_output_file)); + io.set_function("close", sol::overload(&lua::api::io_close_argless, + &lua::api::io_close_file)); + io.set_function("flush", sol::overload(&lua::api::io_flush_argless, + &lua::api::io_flush_file)); + io.set_function("read", &lua::api::io_read); + io.set_function("write", &lua::api::io_write); + io.set_function("popen", &lua::api::io_popen); + io.set_function("tmpfile", &lua::api::io_tmpfile); } void PluginController::load(const QFileInfo &index, const QDir &pluginDir, @@ -314,7 +262,7 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir, << " because safe mode is enabled."; return; } - PluginController::openLibrariesFor(l, meta, pluginDir); + PluginController::openLibrariesFor(temp); if (!PluginController::isPluginEnabled(pluginName) || !getSettings()->pluginsEnabled) @@ -345,17 +293,13 @@ bool PluginController::reload(const QString &id) { return false; } - if (it->second->state_ != nullptr) - { - lua_close(it->second->state_); - it->second->state_ = nullptr; - } + for (const auto &[cmd, _] : it->second->ownedCommands) { getApp()->getCommands()->unregisterPluginCommand(cmd); } - it->second->ownedCommands.clear(); QDir loadDir = it->second->loadDirectory_; + // Since Plugin owns the state, it will clean up everything related to it this->plugins_.erase(id); this->tryLoadFromDir(loadDir); return true; @@ -369,27 +313,36 @@ QString PluginController::tryExecPluginCommand(const QString &commandName, if (auto it = plugin->ownedCommands.find(commandName); it != plugin->ownedCommands.end()) { - const auto &funcName = it->second; + sol::state_view lua(plugin->state_); + sol::table args = lua.create_table_with( + "words", ctx.words, // + "channel", lua::api::ChannelRef(ctx.channel) // + ); - auto *L = plugin->state_; - lua_getfield(L, LUA_REGISTRYINDEX, funcName.toStdString().c_str()); - lua::push(L, ctx); - - auto res = lua_pcall(L, 1, 0, 0); - if (res != LUA_OK) + auto result = + lua::tryCall>(it->second, args); + if (!result) { - ctx.channel->addSystemMessage("Lua error: " + - lua::humanErrorText(L, res)); - return ""; + ctx.channel->addSystemMessage( + QStringView( + u"Failed to evaluate command from plugin %1: %2") + .arg(plugin->meta.name, result.error())); + return {}; } - return ""; + + auto opt = result.value(); + if (!opt) + { + return {}; + } + return *opt; } } qCCritical(chatterinoLua) << "Something's seriously up, no plugin owns command" << commandName << "yet a call to execute it came in"; assert(false && "missing plugin command owner"); - return ""; + return {}; } bool PluginController::isPluginEnabled(const QString &id) @@ -435,32 +388,31 @@ std::pair PluginController::updateCustomCompletions( continue; } - lua::StackGuard guard(pl->state_); - auto opt = pl->getCompletionCallback(); if (opt) { qCDebug(chatterinoLua) << "Processing custom completions from plugin" << name; auto &cb = *opt; - auto errOrList = cb(lua::api::CompletionEvent{ - .query = query, - .full_text_content = fullTextContent, - .cursor_position = cursorPosition, - .is_first_word = isFirstWord, - }); - if (std::holds_alternative(errOrList)) + sol::state_view view(pl->state_); + auto errOrList = lua::tryCall( + cb, + toTable(pl->state_, lua::api::CompletionEvent{ + .query = query, + .full_text_content = fullTextContent, + .cursor_position = cursorPosition, + .is_first_word = isFirstWord, + })); + if (!errOrList.has_value()) { - guard.handled(); - int err = std::get(errOrList); qCDebug(chatterinoLua) << "Got error from plugin " << pl->meta.name << " while refreshing tab completion: " - << lua::humanErrorText(pl->state_, err); + << errOrList.get_unexpected().error(); continue; } - auto list = std::get(errOrList); + auto list = lua::api::CompletionList(*errOrList); if (list.hideOthers) { results = QStringList(list.values.begin(), list.values.end()); diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index 50bc88c7e..ce4fe6170 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -10,6 +10,7 @@ # include # include # include +# include # include # include @@ -66,11 +67,16 @@ private: const PluginMeta &meta); // This function adds lua standard libraries into the state - static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/, - const QDir &pluginDir); + static void openLibrariesFor(Plugin *plugin); + + static void initSol(sol::state_view &lua, Plugin *plugin); + static void loadChatterinoLib(lua_State *l); bool tryLoadFromDir(const QDir &pluginDir); std::map> plugins_; + + // This is for tests, pay no attention + friend class PluginControllerAccess; }; } // namespace chatterino diff --git a/src/controllers/plugins/PluginPermission.hpp b/src/controllers/plugins/PluginPermission.hpp index ffa728e7b..8beb918a1 100644 --- a/src/controllers/plugins/PluginPermission.hpp +++ b/src/controllers/plugins/PluginPermission.hpp @@ -10,6 +10,8 @@ namespace chatterino { struct PluginPermission { explicit PluginPermission(const QJsonObject &obj); + // This is for tests + PluginPermission() = default; enum class Type { FilesystemRead, diff --git a/src/controllers/plugins/SolTypes.cpp b/src/controllers/plugins/SolTypes.cpp new file mode 100644 index 000000000..43de08295 --- /dev/null +++ b/src/controllers/plugins/SolTypes.cpp @@ -0,0 +1,131 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/SolTypes.hpp" + +# include "controllers/plugins/PluginController.hpp" + +# include +# include +namespace chatterino::lua { + +Plugin *ThisPluginState::plugin() +{ + if (this->plugptr_ != nullptr) + { + return this->plugptr_; + } + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(this->state_); + if (pl == nullptr) + { + throw std::runtime_error("internal error: missing plugin"); + } + this->plugptr_ = pl; + return pl; +} + +} // namespace chatterino::lua + +// NOLINTBEGIN(readability-named-parameter) +// QString +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QString sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + auto str = sol::stack::get(L, index, tracking); + return QString::fromUtf8(str.data(), static_cast(str.length())); +} + +int sol_lua_push(sol::types, lua_State *L, const QString &value) +{ + return sol::stack::push(L, value.toUtf8().data()); +} + +// QStringList +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QStringList sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + sol::table table = sol::stack::get(L, index, tracking); + QStringList result; + result.reserve(static_cast(table.size())); + for (size_t i = 1; i < table.size() + 1; i++) + { + result.append(table.get(i)); + } + return result; +} + +int sol_lua_push(sol::types, lua_State *L, + const QStringList &value) +{ + sol::table table = sol::table::create(L, static_cast(value.size())); + for (const QString &str : value) + { + table.add(str); + } + return sol::stack::push(L, table); +} + +// QByteArray +bool sol_lua_check(sol::types, lua_State *L, int index, + std::function handler, + sol::stack::record &tracking) +{ + return sol::stack::check(L, index, handler, tracking); +} + +QByteArray sol_lua_get(sol::types, lua_State *L, int index, + sol::stack::record &tracking) +{ + auto str = sol::stack::get(L, index, tracking); + return QByteArray::fromRawData(str.data(), str.length()); +} + +int sol_lua_push(sol::types, lua_State *L, const QByteArray &value) +{ + return sol::stack::push(L, + std::string_view(value.constData(), value.size())); +} + +namespace chatterino::lua { + +// ThisPluginState + +bool sol_lua_check(sol::types, + lua_State * /*L*/, int /* index*/, + std::function /* handler*/, + sol::stack::record & /*tracking*/) +{ + return true; +} + +chatterino::lua::ThisPluginState sol_lua_get( + sol::types, lua_State *L, int /*index*/, + sol::stack::record &tracking) +{ + tracking.use(0); + return {L}; +} + +int sol_lua_push(sol::types, lua_State *L, + const chatterino::lua::ThisPluginState &value) +{ + return sol::stack::push(L, sol::thread(L, value)); +} + +} // namespace chatterino::lua + +// NOLINTEND(readability-named-parameter) + +#endif diff --git a/src/controllers/plugins/SolTypes.hpp b/src/controllers/plugins/SolTypes.hpp new file mode 100644 index 000000000..3ee41c3a7 --- /dev/null +++ b/src/controllers/plugins/SolTypes.hpp @@ -0,0 +1,170 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "util/QMagicEnum.hpp" +# include "util/TypeName.hpp" + +# include +# include +# include +# include +# include +# include + +namespace chatterino::detail { + +// NOLINTBEGIN(readability-identifier-naming) +template +constexpr bool IsOptional = false; +template +constexpr bool IsOptional> = true; +// NOLINTEND(readability-identifier-naming) + +} // namespace chatterino::detail + +namespace chatterino { + +class Plugin; + +} // namespace chatterino + +namespace chatterino::lua { + +class ThisPluginState +{ +public: + ThisPluginState(lua_State *Ls) + : plugptr_(nullptr) + , state_(Ls) + { + } + + operator lua_State *() const noexcept + { + return this->state_; + } + + lua_State *operator->() const noexcept + { + return this->state_; + } + lua_State *state() const noexcept + { + return this->state_; + } + + Plugin *plugin(); + +private: + Plugin *plugptr_; + lua_State *state_; +}; + +/// @brief Attempts to call @a function with @a args +/// +/// @a T is expected to be returned. +/// If `void` is specified, the returned values +/// are ignored. +/// `std::optional` means nil|LuaEquiv (or zero returns) +/// A return type that doesn't match returns an error +template +inline nonstd::expected_lite::expected tryCall( + const sol::protected_function &function, Args &&...args) +{ + sol::protected_function_result result = + function(std::forward(args)...); + if (!result.valid()) + { + sol::error err = result; + return nonstd::expected_lite::make_unexpected( + QString::fromUtf8(err.what())); + } + + if constexpr (std::is_same_v) + { + return {}; + } + else + { + if constexpr (detail::IsOptional) + { + if (result.return_count() == 0) + { + return {}; + } + } + if (result.return_count() > 1) + { + return nonstd::expected_lite::make_unexpected( + u"Expected one value to be returned but " % + QString::number(result.return_count()) % + u" values were returned"); + } + + try + { + if constexpr (detail::IsOptional) + { + // we want to error on anything that is not nil|T, + // std::optional in sol means "give me a T or if it does not match nullopt" + if (result.get_type() == sol::type::nil) + { + return {}; + } + auto ret = result.get(); + + if (!ret) + { + auto t = type_name(); + return nonstd::expected_lite::make_unexpected( + u"Expected " % QLatin1String(t.data(), t.size()) % + u" to be returned but " % + qmagicenum::enumName(result.get_type()) % + u" was returned"); + } + return *ret; + } + else + { + auto ret = result.get>(); + + if (!ret) + { + auto t = type_name(); + return nonstd::expected_lite::make_unexpected( + u"Expected " % QLatin1String(t.data(), t.size()) % + u" to be returned but " % + qmagicenum::enumName(result.get_type()) % + u" was returned"); + } + return *ret; + } + } + catch (std::runtime_error &e) + { + return nonstd::expected_lite::make_unexpected( + QString::fromUtf8(e.what())); + } + // non other exceptions we let it explode + } +} + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +# define SOL_STACK_FUNCTIONS(TYPE) \ + bool sol_lua_check(sol::types, lua_State *L, int index, \ + std::function handler, \ + sol::stack::record &tracking); \ + TYPE sol_lua_get(sol::types, lua_State *L, int index, \ + sol::stack::record &tracking); \ + int sol_lua_push(sol::types, lua_State *L, const TYPE &value); + +SOL_STACK_FUNCTIONS(chatterino::lua::ThisPluginState) + +} // namespace chatterino::lua + +SOL_STACK_FUNCTIONS(QString) +SOL_STACK_FUNCTIONS(QStringList) +SOL_STACK_FUNCTIONS(QByteArray) + +# undef SOL_STACK_FUNCTIONS + +#endif diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index a57e60119..b9bced3a0 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -1,397 +1,224 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/api/ChannelRef.hpp" +# include "Application.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 "controllers/plugins/SolTypes.hpp" # include "providers/twitch/TwitchChannel.hpp" # include "providers/twitch/TwitchIrcServer.hpp" -extern "C" { -# include -# 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) +ChannelRef::ChannelRef(const std::shared_ptr &chan) + : weak(chan) { - 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) +std::shared_ptr ChannelRef::strong() { - if (lua_gettop(L) < 1) + auto c = this->weak.lock(); + if (!c) { - luaL_error(L, "Called c2.Channel method without a channel object"); - return nullptr; + throw std::runtime_error( + "Expired c2.Channel used - use c2.Channel:is_valid() to " + "check validity"); } - if (lua_isuserdata(L, lua_gettop(L)) == 0) + return c; +} + +std::shared_ptr ChannelRef::twitch() +{ + auto c = std::dynamic_pointer_cast(this->weak.lock()); + if (!c) { - luaL_error( - L, "Called c2.Channel method with a non-userdata 'self' argument"); - return nullptr; + throw std::runtime_error( + "Expired or non-twitch c2.Channel used - use " + "c2.Channel:is_valid() and c2.Channe:is_twitch_channel()"); } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, lua_gettop(L), "c2.Channel"); - auto *data = - WeakPtrUserData::from(checked); - 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) + return c; +} + +bool ChannelRef::is_valid() +{ + return !this->weak.expired(); +} + +QString ChannelRef::get_name() +{ + return this->strong()->getName(); +} + +Channel::Type ChannelRef::get_type() +{ + return this->strong()->getType(); +} + +QString ChannelRef::get_display_name() +{ + return this->strong()->getDisplayName(); +} + +void ChannelRef::send_message(QString text, sol::variadic_args va) +{ + bool execCommands = [&] { + if (va.size() >= 1) { - luaL_error(L, - "Usage of expired c2.Channel object. Underlying " - "resource was freed. Use Channel:is_valid() to check"); + return va.get(); } - 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) + return false; + }(); + text = text.replace('\n', ' '); + auto chan = this->strong(); + if (execCommands) { - luaL_error(L, - "c2.Channel Twitch-only operation on non-Twitch channel."); + text = getApp()->getCommands()->execCommand(text, chan, false); } - return ptr; + chan->sendMessage(text); } -int ChannelRef::is_valid(lua_State *L) +void ChannelRef::add_system_message(QString text) { - ChannelPtr that = ChannelRef::getOrError(L, true); - lua::push(L, that != nullptr); - return 1; + text = text.replace('\n', ' '); + this->strong()->addSystemMessage(text); } -int ChannelRef::get_name(lua_State *L) +bool ChannelRef::is_twitch_channel() { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getName()); - return 1; + return this->strong()->isTwitchChannel(); } -int ChannelRef::get_type(lua_State *L) +sol::table ChannelRef::get_room_modes(sol::this_state state) { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getType()); - return 1; + return toTable(state.L, *this->twitch()->accessRoomModes()); } -int ChannelRef::get_display_name(lua_State *L) +sol::table ChannelRef::get_stream_status(sol::this_state state) { - ChannelPtr that = ChannelRef::getOrError(L); - lua::push(L, that->getDisplayName()); - return 1; + return toTable(state.L, *this->twitch()->accessStreamStatus()); } -int ChannelRef::send_message(lua_State *L) +QString ChannelRef::get_twitch_id() { - if (lua_gettop(L) != 2 && lua_gettop(L) != 3) + return this->twitch()->roomId(); +} + +bool ChannelRef::is_broadcaster() +{ + return this->twitch()->isBroadcaster(); +} + +bool ChannelRef::is_mod() +{ + return this->twitch()->isMod(); +} + +bool ChannelRef::is_vip() +{ + return this->twitch()->isVip(); +} + +QString ChannelRef::to_string() +{ + auto chan = this->weak.lock(); + if (!chan) { - luaL_error(L, "Channel:send_message needs 1 or 2 arguments (message " - "text and optionally execute_commands flag)"); - return 0; + return ""; } - bool execcmds = false; - if (lua_gettop(L) == 3) + return QStringView(u"").arg(chan->getName()); +} + +std::optional ChannelRef::get_by_name(const QString &name) +{ + auto chan = getApp()->getTwitch()->getChannelOrEmpty(name); + if (chan->isEmpty()) { - if (!lua::pop(L, &execcmds)) + return std::nullopt; + } + return chan; +} + +std::optional ChannelRef::get_by_twitch_id(const QString &id) +{ + auto chan = getApp()->getTwitch()->getChannelOrEmptyByID(id); + if (chan->isEmpty()) + { + return std::nullopt; + } + return chan; +} + +void ChannelRef::createUserType(sol::table &c2) +{ + // clang-format off + c2.new_usertype( + "Channel", sol::no_constructor, + // meta methods + sol::meta_method::to_string, &ChannelRef::to_string, + + // Channel + "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, + + // TwitchChannel + "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, + + // static + "by_name", &ChannelRef::get_by_name, + "by_twitch_id", &ChannelRef::get_by_twitch_id + ); + // clang-format on +} + +sol::table toTable(lua_State *L, const TwitchChannel::RoomModes &modes) +{ + auto maybe = [](int value) { + if (value >= 0) { - luaL_error(L, "cannot get execute_commands (2nd argument of " - "Channel:send_message)"); - return 0; + return std::optional{value}; } - } - - 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 = getApp()->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->addSystemMessage(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), - + return std::optional{}; }; - lua::push(L, modes); - return 1; + // clang-format off + return sol::table::create_with(L, + "subscriber_only", modes.submode, + "unique_chat", modes.r9k, + "emotes_only", modes.emoteOnly, + "follower_only", maybe(modes.followerOnly), + "slow_mode", maybe(modes.slowMode) + ); + // clang-format on } -int ChannelRef::get_stream_status(lua_State *L) +sol::table toTable(lua_State *L, const TwitchChannel::StreamStatus &status) { - 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; + // clang-format off + return sol::table::create_with(L, + "live", status.live, + "viewer_count", status.viewerCount, + "title", status.title, + "game_name", status.game, + "game_id", status.gameId, + "uptime", status.uptimeSeconds + ); + // clang-format on } -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()->getTwitch()->getChannelOrEmpty(name); - lua::push(L, chn); - 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()->getTwitch()->getChannelOrEmptyByID(id); - - lua::push(L, chn); - 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; -} - -StackIdx push(lua_State *L, ChannelPtr chn) -{ - using namespace chatterino::lua::api; - - if (chn->isEmpty()) - { - lua_pushnil(L); - return lua_gettop(L); - } - WeakPtrUserData::create( - L, chn->weak_from_this()); - luaL_getmetatable(L, "c2.Channel"); - lua_setmetatable(L, -2); - return lua_gettop(L); -} - -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 32e1946ab..9d4455739 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -1,48 +1,24 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS # include "common/Channel.hpp" -# include "controllers/plugins/LuaUtilities.hpp" -# include "controllers/plugins/PluginController.hpp" # include "providers/twitch/TwitchChannel.hpp" -# include +# include 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 c2.Platform + * @includefile providers/twitch/TwitchChannel.hpp */ -enum class LPlatform { - Twitch, - //IRC, -}; /** * @lua@class c2.Channel */ 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: + ChannelRef(const std::shared_ptr &chan); + /** * Returns true if the channel this object points to is valid. * If the object expired, returns false @@ -51,7 +27,7 @@ public: * @lua@return boolean success * @exposed c2.Channel:is_valid */ - static int is_valid(lua_State *L); + bool is_valid(); /** * Gets the channel's name. This is the lowercase login name. @@ -59,7 +35,7 @@ public: * @lua@return string name * @exposed c2.Channel:get_name */ - static int get_name(lua_State *L); + QString get_name(); /** * Gets the channel's type @@ -67,7 +43,7 @@ public: * @lua@return c2.ChannelType * @exposed c2.Channel:get_type */ - static int get_type(lua_State *L); + Channel::Type get_type(); /** * Get the channel owner's display name. This may contain non-lowercase ascii characters. @@ -75,17 +51,17 @@ public: * @lua@return string name * @exposed c2.Channel:get_display_name */ - static int get_display_name(lua_State *L); + QString get_display_name(); /** * 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? + * @lua@param execute_commands? boolean Should commands be run on the text? * @exposed c2.Channel:send_message */ - static int send_message(lua_State *L); + void send_message(QString text, sol::variadic_args va); /** * Adds a system message client-side @@ -93,7 +69,7 @@ public: * @lua@param message string * @exposed c2.Channel:add_system_message */ - static int add_system_message(lua_State *L); + void add_system_message(QString text); /** * Returns true for twitch channels. @@ -103,7 +79,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_twitch_channel */ - static int is_twitch_channel(lua_State *L); + bool is_twitch_channel(); /** * Twitch Channel specific functions @@ -115,7 +91,7 @@ public: * @lua@return RoomModes * @exposed c2.Channel:get_room_modes */ - static int get_room_modes(lua_State *L); + sol::table get_room_modes(sol::this_state state); /** * Returns a copy of the stream status. @@ -123,7 +99,7 @@ public: * @lua@return StreamStatus * @exposed c2.Channel:get_stream_status */ - static int get_stream_status(lua_State *L); + sol::table get_stream_status(sol::this_state state); /** * Returns the Twitch user ID of the owner of the channel. @@ -131,7 +107,7 @@ public: * @lua@return string * @exposed c2.Channel:get_twitch_id */ - static int get_twitch_id(lua_State *L); + QString get_twitch_id(); /** * Returns true if the channel is a Twitch channel and the user owns it @@ -139,7 +115,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_broadcaster */ - static int is_broadcaster(lua_State *L); + bool is_broadcaster(); /** * Returns true if the channel is a Twitch channel and the user is a moderator in the channel @@ -148,7 +124,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_mod */ - static int is_mod(lua_State *L); + bool is_mod(); /** * Returns true if the channel is a Twitch channel and the user is a VIP in the channel @@ -157,7 +133,7 @@ public: * @lua@return boolean * @exposed c2.Channel:is_vip */ - static int is_vip(lua_State *L); + bool is_vip(); /** * Misc @@ -167,7 +143,7 @@ public: * @lua@return string * @exposed c2.Channel:__tostring */ - static int to_string(lua_State *L); + QString to_string(); /** * Static functions @@ -184,11 +160,10 @@ public: * - /automod * * @lua@param name string Which channel are you looking for? - * @lua@param platform c2.Platform Where to search for the channel? * @lua@return c2.Channel? * @exposed c2.Channel.by_name */ - static int get_by_name(lua_State *L); + static std::optional get_by_name(const QString &name); /** * Finds a channel by the Twitch user ID of its owner. @@ -197,79 +172,24 @@ public: * @lua@return c2.Channel? * @exposed c2.Channel.by_twitch_id */ - static int get_by_twitch_id(lua_State *L); -}; + static std::optional get_by_twitch_id(const QString &id); -// 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; + static void createUserType(sol::table &c2); - /** - * @lua@field subscriber_only boolean - */ - bool subscriber_only = false; +private: + std::weak_ptr weak; - /** - * @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; + /// Locks the weak pointer and throws if the pointer expired + std::shared_ptr strong(); - /** - * @lua@field follower_only 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; + /// Locks the weak pointer and throws if the pointer is invalid + std::shared_ptr twitch(); }; // NOLINTEND(readability-identifier-naming) + +sol::table toTable(lua_State *L, const TwitchChannel::RoomModes &modes); +sol::table toTable(lua_State *L, const TwitchChannel::StreamStatus &status); + } // 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); -StackIdx push(lua_State *L, ChannelPtr chn); -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/EventType.hpp b/src/controllers/plugins/api/EventType.hpp new file mode 100644 index 000000000..73b5df135 --- /dev/null +++ b/src/controllers/plugins/api/EventType.hpp @@ -0,0 +1,14 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS + +namespace chatterino::lua::api { + +/** + * @exposeenum c2.EventType + */ +enum class EventType { + CompletionRequested, +}; + +} // namespace chatterino::lua::api +#endif diff --git a/src/controllers/plugins/api/HTTPRequest.cpp b/src/controllers/plugins/api/HTTPRequest.cpp index eba2773ad..a1a97f616 100644 --- a/src/controllers/plugins/api/HTTPRequest.cpp +++ b/src/controllers/plugins/api/HTTPRequest.cpp @@ -6,402 +6,173 @@ # include "common/network/NetworkRequest.hpp" # include "common/network/NetworkResult.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" -# include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "util/DebugCount.hpp" -extern "C" { # include # include -} +# include +# include # include # include +# include +# include +# include +# include +# include -# include +# include +# include # include +# include namespace chatterino::lua::api { -// NOLINTBEGIN(*vararg) -// NOLINTNEXTLINE(*-avoid-c-arrays) -static const luaL_Reg HTTP_REQUEST_METHODS[] = { - {"on_success", &HTTPRequest::on_success_wrap}, - {"on_error", &HTTPRequest::on_error_wrap}, - {"finally", &HTTPRequest::finally_wrap}, - {"execute", &HTTPRequest::execute_wrap}, - {"set_timeout", &HTTPRequest::set_timeout_wrap}, - {"set_payload", &HTTPRequest::set_payload_wrap}, - {"set_header", &HTTPRequest::set_header_wrap}, - // static - {"create", &HTTPRequest::create}, - {nullptr, nullptr}, -}; - -std::shared_ptr HTTPRequest::getOrError(lua_State *L, - StackIdx where) +void HTTPRequest::createUserType(sol::table &c2) { - if (lua_gettop(L) < 1) - { - // The nullptr is there just to appease the compiler, luaL_error is no return - luaL_error(L, "Called c2.HTTPRequest method without a request object"); - return nullptr; - } - if (lua_isuserdata(L, where) == 0) - { - luaL_error( - L, - "Called c2.HTTPRequest method with a non-userdata 'self' argument"); - return nullptr; - } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, where, "c2.HTTPRequest"); - auto *data = - SharedPtrUserData::from( - checked); - if (data == nullptr) - { - luaL_error(L, "Called c2.HTTPRequest method with an invalid pointer"); - return nullptr; - } - lua_remove(L, where); - if (data->target == nullptr) - { - luaL_error( - L, "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); - return nullptr; - } - if (data->target->done) - { - luaL_error(L, "This c2.HTTPRequest has already been executed!"); - return nullptr; - } - return data->target; + c2.new_usertype( // + "HTTPRequest", sol::no_constructor, // + sol::meta_method::to_string, &HTTPRequest::to_string, // + + "on_success", &HTTPRequest::on_success, // + "on_error", &HTTPRequest::on_error, // + "finally", &HTTPRequest::finally, // + + "set_timeout", &HTTPRequest::set_timeout, // + "set_payload", &HTTPRequest::set_payload, // + "set_header", &HTTPRequest::set_header, // + "execute", &HTTPRequest::execute, // + + "create", &HTTPRequest::create // + ); } -void HTTPRequest::createMetatable(lua_State *L) +void HTTPRequest::on_success(sol::protected_function func) { - lua::StackGuard guard(L, 1); - - luaL_newmetatable(L, "c2.HTTPRequest"); - lua_pushstring(L, "__index"); - lua_pushvalue(L, -2); // clone metatable - lua_settable(L, -3); // metatable.__index = metatable - - // Generic ISharedResource stuff - lua_pushstring(L, "__gc"); - lua_pushcfunction(L, (&SharedPtrUserData::destroy)); - lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy - - luaL_setfuncs(L, HTTP_REQUEST_METHODS, 0); + this->cbSuccess = std::make_optional(func); } -int HTTPRequest::on_success_wrap(lua_State *L) +void HTTPRequest::on_error(sol::protected_function func) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->on_success(L); + this->cbError = std::make_optional(func); } -int HTTPRequest::on_success(lua_State *L) +void HTTPRequest::set_timeout(int timeout) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:on_success needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error( - L, "HTTPRequest:on_success needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "success"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; + this->timeout_ = timeout; } -int HTTPRequest::on_error_wrap(lua_State *L) +void HTTPRequest::finally(sol::protected_function func) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->on_error(L); + this->cbFinally = std::make_optional(func); } -int HTTPRequest::on_error(lua_State *L) +void HTTPRequest::set_payload(QByteArray payload) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:on_error needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error( - L, "HTTPRequest:on_error needs 1 argument (a callback " - "that takes an HTTPResult and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "error"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; + this->req_ = std::move(this->req_).payload(payload); } -int HTTPRequest::set_timeout_wrap(lua_State *L) +// name and value may be random bytes +void HTTPRequest::set_header(QByteArray name, QByteArray value) { - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_timeout(L); + this->req_ = std::move(this->req_).header(name, value); } -int HTTPRequest::set_timeout(lua_State *L) +std::shared_ptr HTTPRequest::create(sol::this_state L, + NetworkRequestType method, + QString url) { - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:set_timeout needs 1 argument (a number of " - "milliseconds after which the request will time out)"); - } - - int temporary = -1; - if (!lua::pop(L, &temporary)) - { - return luaL_error( - L, "HTTPRequest:set_timeout failed to get timeout, expected a " - "positive integer"); - } - if (temporary <= 0) - { - return luaL_error( - L, "HTTPRequest:set_timeout failed to get timeout, expected a " - "positive integer"); - } - this->timeout_ = temporary; - return 0; -} - -int HTTPRequest::finally_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->finally(L); -} - -int HTTPRequest::finally(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " - "that takes nothing and doesn't return anything)"); - } - if (!lua_isfunction(L, top)) - { - return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " - "that takes nothing and doesn't return anything)"); - } - auto shared = this->pushPrivate(L); - lua_pushvalue(L, -2); - lua_setfield(L, shared, "finally"); // this deletes the function copy - lua_pop(L, 2); // delete the table and function original - return 0; -} - -int HTTPRequest::set_payload_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -2); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_payload(L); -} - -int HTTPRequest::set_payload(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 1) - { - return luaL_error( - L, "HTTPRequest:set_payload needs 1 argument (a string payload)"); - } - - std::string temporary; - if (!lua::pop(L, &temporary)) - { - return luaL_error( - L, "HTTPRequest:set_payload failed to get payload, expected a " - "string"); - } - this->req_ = - std::move(this->req_).payload(QByteArray::fromStdString(temporary)); - return 0; -} - -int HTTPRequest::set_header_wrap(lua_State *L) -{ - lua::StackGuard guard(L, -3); - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->set_header(L); -} - -int HTTPRequest::set_header(lua_State *L) -{ - auto top = lua_gettop(L); - if (top != 2) - { - return luaL_error( - L, "HTTPRequest:set_header needs 2 arguments (a header name " - "and a value)"); - } - - std::string value; - if (!lua::pop(L, &value)) - { - return luaL_error( - L, "cannot get value (2nd argument of HTTPRequest:set_header)"); - } - std::string name; - if (!lua::pop(L, &name)) - { - return luaL_error( - L, "cannot get name (1st argument of HTTPRequest:set_header)"); - } - this->req_ = std::move(this->req_) - .header(QByteArray::fromStdString(name), - QByteArray::fromStdString(value)); - return 0; -} - -int HTTPRequest::create(lua_State *L) -{ - lua::StackGuard guard(L, -1); - if (lua_gettop(L) != 2) - { - return luaL_error( - L, "HTTPRequest.create needs exactly 2 arguments (method " - "and url)"); - } - QString url; - if (!lua::pop(L, &url)) - { - return luaL_error(L, - "cannot get url (2nd argument of HTTPRequest.create, " - "expected a string)"); - } auto parsedurl = QUrl(url); if (!parsedurl.isValid()) { - return luaL_error( - L, "cannot parse url (2nd argument of HTTPRequest.create, " - "got invalid url in argument)"); - } - NetworkRequestType method{}; - if (!lua::pop(L, &method)) - { - return luaL_error( - L, "cannot get method (1st argument of HTTPRequest.create, " - "expected a string)"); + throw std::runtime_error( + "cannot parse url (2nd argument of HTTPRequest.create, " + "got invalid url in argument)"); } auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (!pl->hasHTTPPermissionFor(parsedurl)) { - return luaL_error( - L, "Plugin does not have permission to send HTTP requests " - "to this URL"); + throw std::runtime_error( + "Plugin does not have permission to send HTTP requests " + "to this URL"); } NetworkRequest r(parsedurl, method); - lua::push( - L, std::make_shared(ConstructorAccessTag{}, std::move(r))); - return 1; + return std::make_shared(ConstructorAccessTag{}, std::move(r)); } -int HTTPRequest::execute_wrap(lua_State *L) +void HTTPRequest::execute(sol::this_state L) { - auto ptr = HTTPRequest::getOrError(L, 1); - return ptr->execute(L); -} - -int HTTPRequest::execute(lua_State *L) -{ - auto shared = this->shared_from_this(); + if (this->done) + { + throw std::runtime_error( + "Cannot execute this c2.HTTPRequest, it was executed already!"); + } this->done = true; + + // this keeps the object alive even if Lua were to forget about it, + auto hack = this->weak_from_this(); + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + pl->httpRequests.push_back(this->shared_from_this()); + std::move(this->req_) - .onSuccess([shared, L](const NetworkResult &res) { + .onSuccess([L, hack](const NetworkResult &res) { + auto self = hack.lock(); + if (!self) + { + return; + } + if (!self->cbSuccess.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "success"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - lua::push(thread, std::make_shared(res)); - // one arg, no return, no msgh - lua_pcall(thread, 1, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L + (*self->cbSuccess)(HTTPResponse(res)); + self->cbSuccess = std::nullopt; }) - .onError([shared, L](const NetworkResult &res) { + .onError([L, hack](const NetworkResult &res) { + auto self = hack.lock(); + if (!self) + { + return; + } + if (!self->cbError.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "error"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - lua::push(thread, std::make_shared(res)); - // one arg, no return, no msgh - lua_pcall(thread, 1, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L + (*self->cbError)(HTTPResponse(res)); + self->cbError = std::nullopt; }) - .finally([shared, L]() { + .finally([L, hack]() { + auto self = hack.lock(); + if (!self) + { + // this could happen if the plugin was deleted + return; + } + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + for (auto it = pl->httpRequests.begin(); + it < pl->httpRequests.end(); it++) + { + if (*it == self) + { + pl->httpRequests.erase(it); + break; + } + } + + if (!self->cbFinally.has_value()) + { + return; + } lua::StackGuard guard(L); - auto *thread = lua_newthread(L); - - auto priv = shared->pushPrivate(thread); - lua_getfield(thread, priv, "finally"); - auto cb = lua_gettop(thread); - if (lua_isfunction(thread, cb)) - { - // no args, no return, no msgh - lua_pcall(thread, 0, 0, 0); - } - else - { - lua_pop(thread, 1); // remove callback - } - // remove our private data - lua_pushnil(thread); - lua_setfield(thread, LUA_REGISTRYINDEX, - shared->privateKey.toStdString().c_str()); - lua_closethread(thread, nullptr); - lua_pop(L, 1); // remove thread from L - - // we removed our private table, forget the key for it - shared->privateKey = QString(); + (*self->cbFinally)(); + self->cbFinally = std::nullopt; }) .timeout(this->timeout_) .execute(); - return 0; } HTTPRequest::HTTPRequest(HTTPRequest::ConstructorAccessTag /*ignored*/, @@ -418,34 +189,10 @@ HTTPRequest::~HTTPRequest() // but that's better than accessing a possibly invalid lua_State pointer. } -StackIdx HTTPRequest::pushPrivate(lua_State *L) +QString HTTPRequest::to_string() { - if (this->privateKey.isEmpty()) - { - this->privateKey = QString("HTTPRequestPrivate%1") - .arg(QRandomGenerator::system()->generate()); - pushEmptyTable(L, 4); - lua_setfield(L, LUA_REGISTRYINDEX, - this->privateKey.toStdString().c_str()); - } - lua_getfield(L, LUA_REGISTRYINDEX, this->privateKey.toStdString().c_str()); - return lua_gettop(L); + return ""; } -// NOLINTEND(*vararg) } // namespace chatterino::lua::api - -namespace chatterino::lua { - -StackIdx push(lua_State *L, std::shared_ptr request) -{ - using namespace chatterino::lua::api; - - SharedPtrUserData::create( - L, std::move(request)); - luaL_getmetatable(L, "c2.HTTPRequest"); - lua_setmetatable(L, -2); - return lua_gettop(L); -} -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/HTTPRequest.hpp b/src/controllers/plugins/api/HTTPRequest.hpp index 955a3cd2d..6fe3b97be 100644 --- a/src/controllers/plugins/api/HTTPRequest.hpp +++ b/src/controllers/plugins/api/HTTPRequest.hpp @@ -2,10 +2,16 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "common/network/NetworkRequest.hpp" # include "controllers/plugins/LuaUtilities.hpp" -# include "controllers/plugins/PluginController.hpp" + +# include +# include # include +namespace chatterino { +class PluginController; +} // namespace chatterino + namespace chatterino::lua::api { // NOLINTBEGIN(readability-identifier-naming) @@ -33,33 +39,19 @@ public: private: NetworkRequest req_; - static void createMetatable(lua_State *L); + static void createUserType(sol::table &c2); friend class chatterino::PluginController; - /** - * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPRequest - * - * If the object given is not a userdatum or the pointer inside that - * userdatum doesn't point to a HTTPRequest, a lua error is thrown. - * - * This function always returns a non-null pointer. - */ - static std::shared_ptr getOrError(lua_State *L, - StackIdx where = -1); - /** - * Pushes the private table onto the lua stack. - * - * This might create it if it doesn't exist. - */ - StackIdx pushPrivate(lua_State *L); - // This is the key in the registry the private table it held at (if it exists) // This might be a null QString if the request has already been executed or // the table wasn't created yet. - QString privateKey; int timeout_ = 10'000; bool done = false; + std::optional cbSuccess; + std::optional cbError; + std::optional cbFinally; + public: // These functions are wrapped so data can be accessed more easily. When a call from Lua comes in: // - the static wrapper function is called @@ -72,8 +64,7 @@ public: * @lua@param callback HTTPCallback Function to call when the HTTP request succeeds * @exposed HTTPRequest:on_success */ - static int on_success_wrap(lua_State *L); - int on_success(lua_State *L); + void on_success(sol::protected_function func); /** * Sets the failure callback @@ -81,8 +72,7 @@ public: * @lua@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status * @exposed HTTPRequest:on_error */ - static int on_error_wrap(lua_State *L); - int on_error(lua_State *L); + void on_error(sol::protected_function func); /** * Sets the finally callback @@ -90,8 +80,7 @@ public: * @lua@param callback fun(): nil Function to call when the HTTP request finishes * @exposed HTTPRequest:finally */ - static int finally_wrap(lua_State *L); - int finally(lua_State *L); + void finally(sol::protected_function func); /** * Sets the timeout @@ -99,8 +88,7 @@ public: * @lua@param timeout integer How long in milliseconds until the times out * @exposed HTTPRequest:set_timeout */ - static int set_timeout_wrap(lua_State *L); - int set_timeout(lua_State *L); + void set_timeout(int timeout); /** * Sets the request payload @@ -108,8 +96,7 @@ public: * @lua@param data string * @exposed HTTPRequest:set_payload */ - static int set_payload_wrap(lua_State *L); - int set_payload(lua_State *L); + void set_payload(QByteArray payload); /** * Sets a header in the request @@ -118,16 +105,19 @@ public: * @lua@param value string * @exposed HTTPRequest:set_header */ - static int set_header_wrap(lua_State *L); - int set_header(lua_State *L); + void set_header(QByteArray name, QByteArray value); /** * Executes the HTTP request * * @exposed HTTPRequest:execute */ - static int execute_wrap(lua_State *L); - int execute(lua_State *L); + void execute(sol::this_state L); + /** + * @lua@return string + * @exposed HTTPRequest:__tostring + */ + QString to_string(); /** * Static functions @@ -142,7 +132,9 @@ public: * @lua@return HTTPRequest * @exposed HTTPRequest.create */ - static int create(lua_State *L); + static std::shared_ptr create(sol::this_state L, + NetworkRequestType method, + QString url); }; // NOLINTEND(readability-identifier-naming) diff --git a/src/controllers/plugins/api/HTTPResponse.cpp b/src/controllers/plugins/api/HTTPResponse.cpp index f6d6ea1df..e18d7bf1f 100644 --- a/src/controllers/plugins/api/HTTPResponse.cpp +++ b/src/controllers/plugins/api/HTTPResponse.cpp @@ -2,77 +2,28 @@ # include "controllers/plugins/api/HTTPResponse.hpp" # include "common/network/NetworkResult.hpp" -# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/SolTypes.hpp" # include "util/DebugCount.hpp" -extern "C" { # include -} +# include +# include + # include namespace chatterino::lua::api { -// NOLINTBEGIN(*vararg) -// NOLINTNEXTLINE(*-avoid-c-arrays) -static const luaL_Reg HTTP_RESPONSE_METHODS[] = { - {"data", &HTTPResponse::data_wrap}, - {"status", &HTTPResponse::status_wrap}, - {"error", &HTTPResponse::error_wrap}, - {nullptr, nullptr}, -}; -void HTTPResponse::createMetatable(lua_State *L) +void HTTPResponse::createUserType(sol::table &c2) { - lua::StackGuard guard(L, 1); + c2.new_usertype( // + "HTTPResponse", sol::no_constructor, + // metamethods + sol::meta_method::to_string, &HTTPResponse::to_string, // - luaL_newmetatable(L, "c2.HTTPResponse"); - lua_pushstring(L, "__index"); - lua_pushvalue(L, -2); // clone metatable - lua_settable(L, -3); // metatable.__index = metatable - - // Generic ISharedResource stuff - lua_pushstring(L, "__gc"); - lua_pushcfunction(L, (&SharedPtrUserData::destroy)); - lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy - - luaL_setfuncs(L, HTTP_RESPONSE_METHODS, 0); -} - -std::shared_ptr HTTPResponse::getOrError(lua_State *L, - StackIdx where) -{ - if (lua_gettop(L) < 1) - { - // The nullptr is there just to appease the compiler, luaL_error is no return - luaL_error(L, "Called c2.HTTPResponse method without a request object"); - return nullptr; - } - if (lua_isuserdata(L, where) == 0) - { - luaL_error(L, "Called c2.HTTPResponse method with a non-userdata " - "'self' argument"); - return nullptr; - } - // luaL_checkudata is no-return if check fails - auto *checked = luaL_checkudata(L, where, "c2.HTTPResponse"); - auto *data = - SharedPtrUserData::from( - checked); - if (data == nullptr) - { - luaL_error(L, "Called c2.HTTPResponse method with an invalid pointer"); - return nullptr; - } - lua_remove(L, where); - if (data->target == nullptr) - { - luaL_error( - L, - "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); - return nullptr; - } - return data->target; + "data", &HTTPResponse::data, // + "status", &HTTPResponse::status, // + "error", &HTTPResponse::error // + ); } HTTPResponse::HTTPResponse(NetworkResult res) @@ -85,60 +36,30 @@ HTTPResponse::~HTTPResponse() DebugCount::decrease("lua::api::HTTPResponse"); } -int HTTPResponse::data_wrap(lua_State *L) +QByteArray HTTPResponse::data() { - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->data(L); + return this->result_.getData(); } -int HTTPResponse::data(lua_State *L) +std::optional HTTPResponse::status() { - lua::push(L, this->result_.getData().toStdString()); - return 1; + return this->result_.status(); } -int HTTPResponse::status_wrap(lua_State *L) +QString HTTPResponse::error() { - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->status(L); + return this->result_.formatError(); } -int HTTPResponse::status(lua_State *L) +QString HTTPResponse::to_string() { - lua::push(L, this->result_.status()); - return 1; + if (this->status().has_value()) + { + return QStringView(u"") + .arg(QString::number(*this->status())); + } + return ""; } -int HTTPResponse::error_wrap(lua_State *L) -{ - lua::StackGuard guard(L, 0); // 1 in, 1 out - auto ptr = HTTPResponse::getOrError(L, 1); - return ptr->error(L); -} - -int HTTPResponse::error(lua_State *L) -{ - lua::push(L, this->result_.formatError()); - return 1; -} - -// NOLINTEND(*vararg) } // namespace chatterino::lua::api - -namespace chatterino::lua { -StackIdx push(lua_State *L, std::shared_ptr request) -{ - using namespace chatterino::lua::api; - - // Prepare table - SharedPtrUserData::create( - L, std::move(request)); - luaL_getmetatable(L, "c2.HTTPResponse"); - lua_setmetatable(L, -2); - - return lua_gettop(L); -} -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/HTTPResponse.hpp b/src/controllers/plugins/api/HTTPResponse.hpp index 205aae01e..80eb49bd3 100644 --- a/src/controllers/plugins/api/HTTPResponse.hpp +++ b/src/controllers/plugins/api/HTTPResponse.hpp @@ -1,12 +1,11 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS # include "common/network/NetworkResult.hpp" -# include "controllers/plugins/LuaUtilities.hpp" + +# include +# include # include -extern "C" { -# include -} namespace chatterino { class PluginController; @@ -18,7 +17,7 @@ namespace chatterino::lua::api { /** * @lua@class HTTPResponse */ -class HTTPResponse : public std::enable_shared_from_this +class HTTPResponse { NetworkResult result_; @@ -31,20 +30,9 @@ public: ~HTTPResponse(); private: - static void createMetatable(lua_State *L); + static void createUserType(sol::table &c2); friend class chatterino::PluginController; - /** - * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPResponse - * - * If the object given is not a userdatum or the pointer inside that - * userdatum doesn't point to a HTTPResponse, a lua error is thrown. - * - * This function always returns a non-null pointer. - */ - static std::shared_ptr getOrError(lua_State *L, - StackIdx where = -1); - public: /** * Returns the data. This is not guaranteed to be encoded using any @@ -52,29 +40,28 @@ public: * * @exposed HTTPResponse:data */ - static int data_wrap(lua_State *L); - int data(lua_State *L); + QByteArray data(); /** * Returns the status code. * * @exposed HTTPResponse:status */ - static int status_wrap(lua_State *L); - int status(lua_State *L); + std::optional status(); /** * A somewhat human readable description of an error if such happened * @exposed HTTPResponse:error */ + QString error(); - static int error_wrap(lua_State *L); - int error(lua_State *L); + /** + * @lua@return string + * @exposed HTTPResponse:__tostring + */ + QString to_string(); }; // NOLINTEND(readability-identifier-naming) } // namespace chatterino::lua::api -namespace chatterino::lua { -StackIdx push(lua_State *L, std::shared_ptr request); -} // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/api/IOWrapper.cpp b/src/controllers/plugins/api/IOWrapper.cpp index f6a58a0bb..b3e4103b9 100644 --- a/src/controllers/plugins/api/IOWrapper.cpp +++ b/src/controllers/plugins/api/IOWrapper.cpp @@ -2,15 +2,17 @@ # include "controllers/plugins/api/IOWrapper.hpp" # include "Application.hpp" -# include "controllers/plugins/LuaUtilities.hpp" +# include "common/QLogging.hpp" # include "controllers/plugins/PluginController.hpp" -extern "C" { # include # include -} +# include +# include # include +# include +# include namespace chatterino::lua::api { @@ -91,45 +93,28 @@ struct LuaFileMode { } }; -int ioError(lua_State *L, const QString &value, int errnoequiv) +sol::variadic_results ioError(lua_State *L, const QString &value, + int errnoequiv) { - lua_pushnil(L); - lua::push(L, value); - lua::push(L, errnoequiv); - return 3; + sol::variadic_results out; + out.push_back(sol::nil); + out.push_back(sol::make_object(L, value.toStdString())); + out.push_back({L, sol::in_place_type, errnoequiv}); + return out; } -// NOLINTBEGIN(*vararg) -int io_open(lua_State *L) +sol::variadic_results io_open(sol::this_state L, QString filename, + QString strmode) { auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (pl == nullptr) { - luaL_error(L, "internal error: no plugin"); - return 0; + throw std::runtime_error("internal error: no plugin"); } - LuaFileMode mode; - if (lua_gettop(L) == 2) + LuaFileMode mode(strmode); + if (!mode.error.isEmpty()) { - // we have a mode - QString smode; - if (!lua::pop(L, &smode)) - { - return luaL_error( - L, - "io.open mode (2nd argument) must be a string or not present"); - } - mode = LuaFileMode(smode); - if (!mode.error.isEmpty()) - { - return luaL_error(L, mode.error.toStdString().c_str()); - } - } - QString filename; - if (!lua::pop(L, &filename)) - { - return luaL_error(L, - "io.open filename (1st argument) must be a string"); + throw std::runtime_error(mode.error.toStdString()); } QFileInfo file(pl->dataDirectory().filePath(filename)); auto abs = file.absoluteFilePath(); @@ -144,39 +129,35 @@ int io_open(lua_State *L) "Plugin does not have permissions to access given file.", EACCES); } - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "open"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua::push(L, abs); - lua::push(L, mode.toString()); - lua_call(L, 2, 3); - return 3; + + sol::state_view lua(L); + auto open = lua.registry()[REG_REAL_IO_NAME]["open"]; + sol::protected_function_result res = + open(abs.toStdString(), mode.toString().toStdString()); + return res; +} +sol::variadic_results io_open_modeless(sol::this_state L, QString filename) +{ + return io_open(L, std::move(filename), "r"); } -int io_lines(lua_State *L) +sol::variadic_results io_lines_noargs(sol::this_state L) +{ + sol::state_view lua(L); + auto lines = lua.registry()[REG_REAL_IO_NAME]["lines"]; + sol::protected_function_result res = lines(); + return res; +} + +sol::variadic_results io_lines(sol::this_state L, QString filename, + sol::variadic_args args) { auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); if (pl == nullptr) { - luaL_error(L, "internal error: no plugin"); - return 0; - } - if (lua_gettop(L) == 0) - { - // io.lines() case, just call realio.lines - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "lines"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_call(L, 0, 1); - return 1; - } - QString filename; - if (!lua::pop(L, &filename)) - { - return luaL_error( - L, - "io.lines filename (1st argument) must be a string or not present"); + throw std::runtime_error("internal error: no plugin"); } + sol::state_view lua(L); QFileInfo file(pl->dataDirectory().filePath(filename)); auto abs = file.absoluteFilePath(); qCDebug(chatterinoLua) << "[" << pl->id << ":" << pl->meta.name @@ -185,191 +166,168 @@ int io_lines(lua_State *L) bool ok = pl->hasFSPermissionFor(false, abs); if (!ok) { - return ioError(L, - "Plugin does not have permissions to access given file.", - EACCES); + throw std::runtime_error( + "Plugin does not have permissions to access given file."); } - // Our stack looks like this: - // - {...}[1] - // - {...}[2] - // ... - // We want: - // - REG[REG_REAL_IO_NAME].lines - // - absolute file path - // - {...}[1] - // - {...}[2] - // ... - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - lua_getfield(L, -1, "lines"); - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_insert(L, 1); // move function to start of stack - lua::push(L, abs); - lua_insert(L, 2); // move file name just after the function - lua_call(L, lua_gettop(L) - 1, LUA_MULTRET); - return lua_gettop(L); + auto lines = lua.registry()[REG_REAL_IO_NAME]["lines"]; + sol::protected_function_result res = lines(abs.toStdString(), args); + return res; } -namespace { - - // This is the code for both io.input and io.output - int globalFileCommon(lua_State *L, bool output) +sol::variadic_results io_input_argless(sol::this_state L) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); - if (pl == nullptr) - { - luaL_error(L, "internal error: no plugin"); - return 0; - } - // Three signature cases: - // io.input() - // io.input(file) - // io.input(name) - if (lua_gettop(L) == 0) - { - // We have no arguments, call realio.input() - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - if (output) - { - lua_getfield(L, -1, "output"); - } - else - { - lua_getfield(L, -1, "input"); - } - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_call(L, 0, 1); - return 1; - } - if (lua_gettop(L) != 1) - { - return luaL_error(L, "Too many arguments given to io.input()."); - } - // Now check if we have a file or name - auto *p = luaL_testudata(L, 1, LUA_FILEHANDLE); - if (p == nullptr) - { - // this is not a file handle, send it to open - luaL_getsubtable(L, LUA_REGISTRYINDEX, REG_C2_IO_NAME); - lua_getfield(L, -1, "open"); - lua_remove(L, -2); // remove io - - lua_pushvalue(L, 1); // dupe arg - if (output) - { - lua_pushstring(L, "w"); - } - else - { - lua_pushstring(L, "r"); - } - lua_call(L, 2, 1); // call ourio.open(arg1, 'r'|'w') - // if this isn't a string ourio.open errors - - // this leaves us with: - // 1. arg - // 2. new_file - lua_remove(L, 1); // remove arg, replacing it with new_file - } - - // file handle, pass it off to realio.input - lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); - if (output) - { - lua_getfield(L, -1, "output"); - } - else - { - lua_getfield(L, -1, "input"); - } - lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] - lua_pushvalue(L, 1); // duplicate arg - lua_call(L, 1, 1); - return 1; + throw std::runtime_error("internal error: no plugin"); } + sol::state_view lua(L); -} // namespace - -int io_input(lua_State *L) -{ - return globalFileCommon(L, false); + auto func = lua.registry()[REG_REAL_IO_NAME]["input"]; + sol::protected_function_result res = func(); + return res; } - -int io_output(lua_State *L) +sol::variadic_results io_input_file(sol::this_state L, sol::userdata file) { - return globalFileCommon(L, true); -} - -int io_close(lua_State *L) -{ - if (lua_gettop(L) > 1) + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - return luaL_error( - L, "Too many arguments for io.close. Expected one or zero."); + throw std::runtime_error("internal error: no plugin"); } - if (lua_gettop(L) == 0) + sol::state_view lua(L); + + auto func = lua.registry()[REG_REAL_IO_NAME]["input"]; + sol::protected_function_result res = func(file); + return res; +} +sol::variadic_results io_input_name(sol::this_state L, QString filename) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) { - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); + throw std::runtime_error("internal error: no plugin"); } - lua_getfield(L, -1, "close"); - lua_pushvalue(L, -2); - lua_call(L, 1, 0); - return 0; -} - -int io_flush(lua_State *L) -{ - if (lua_gettop(L) > 1) + sol::state_view lua(L); + auto res = io_open(L, std::move(filename), "r"); + if (res.size() != 1) { - return luaL_error( - L, "Too many arguments for io.flush. Expected one or zero."); + throw std::runtime_error(res.at(1).as()); } - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); - lua_getfield(L, -1, "flush"); - lua_pushvalue(L, -2); - lua_call(L, 1, 0); - return 0; -} - -int io_read(lua_State *L) -{ - if (lua_gettop(L) > 1) + auto obj = res.at(0); + if (obj.get_type() != sol::type::userdata) { - return luaL_error( - L, "Too many arguments for io.read. Expected one or zero."); + throw std::runtime_error("a file must be a userdata."); } - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_input"); - lua_getfield(L, -1, "read"); - lua_insert(L, 1); - lua_insert(L, 2); - lua_call(L, lua_gettop(L) - 1, 1); - return 1; + return io_input_file(L, obj); } -int io_write(lua_State *L) +sol::variadic_results io_output_argless(sol::this_state L) { - lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); - lua_getfield(L, -1, "write"); - lua_insert(L, 1); - lua_insert(L, 2); - // (input) - // (input).read - // args - lua_call(L, lua_gettop(L) - 1, 1); - return 1; -} + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); -int io_popen(lua_State *L) + auto func = lua.registry()[REG_REAL_IO_NAME]["output"]; + sol::protected_function_result res = func(); + return res; +} +sol::variadic_results io_output_file(sol::this_state L, sol::userdata file) { - return luaL_error(L, "io.popen: This function is a stub!"); -} + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); -int io_tmpfile(lua_State *L) + auto func = lua.registry()[REG_REAL_IO_NAME]["output"]; + sol::protected_function_result res = func(file); + return res; +} +sol::variadic_results io_output_name(sol::this_state L, QString filename) { - return luaL_error(L, "io.tmpfile: This function is a stub!"); + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + throw std::runtime_error("internal error: no plugin"); + } + sol::state_view lua(L); + auto res = io_open(L, std::move(filename), "w"); + if (res.size() != 1) + { + throw std::runtime_error(res.at(1).as()); + } + auto obj = res.at(0); + if (obj.get_type() != sol::type::userdata) + { + throw std::runtime_error("internal error: a file must be a userdata."); + } + return io_output_file(L, obj); } -// NOLINTEND(*vararg) +bool io_close_argless(sol::this_state L) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + return io_close_file(L, out); +} + +bool io_close_file(sol::this_state L, sol::userdata file) +{ + sol::state_view lua(L); + return file["close"](file); +} + +void io_flush_argless(sol::this_state L) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + io_flush_file(L, out); +} + +void io_flush_file(sol::this_state L, sol::userdata file) +{ + sol::state_view lua(L); + file["flush"](file); +} + +sol::variadic_results io_read(sol::this_state L, sol::variadic_args args) +{ + sol::state_view lua(L); + auto inp = lua.registry()["_IO_input"]; + if (!inp.is()) + { + throw std::runtime_error("Input not set to a file"); + } + sol::protected_function read = inp["read"]; + return read(inp, args); +} + +sol::variadic_results io_write(sol::this_state L, sol::variadic_args args) +{ + sol::state_view lua(L); + auto out = lua.registry()["_IO_output"]; + if (!out.is()) + { + throw std::runtime_error("Output not set to a file"); + } + sol::protected_function write = out["write"]; + return write(out, args); +} + +void io_popen() +{ + throw std::runtime_error("io.popen: This function is a stub!"); +} + +void io_tmpfile() +{ + throw std::runtime_error("io.tmpfile: This function is a stub!"); +} } // namespace chatterino::lua::api #endif diff --git a/src/controllers/plugins/api/IOWrapper.hpp b/src/controllers/plugins/api/IOWrapper.hpp index 24ee2801e..16b1ae178 100644 --- a/src/controllers/plugins/api/IOWrapper.hpp +++ b/src/controllers/plugins/api/IOWrapper.hpp @@ -1,5 +1,9 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include +# include +# include +# include struct lua_State; @@ -8,7 +12,6 @@ namespace chatterino::lua::api { // These functions are exposed as `_G.io`, they are wrappers for native Lua functionality. const char *const REG_REAL_IO_NAME = "real_lua_io_lib"; -const char *const REG_C2_IO_NAME = "c2io"; /** * Opens a file. @@ -20,7 +23,9 @@ const char *const REG_C2_IO_NAME = "c2io"; * @lua@param mode nil|"r"|"w"|"a"|"r+"|"w+"|"a+" * @exposed io.open */ -int io_open(lua_State *L); +sol::variadic_results io_open(sol::this_state L, QString filename, + QString strmode); +sol::variadic_results io_open_modeless(sol::this_state L, QString filename); /** * Equivalent to io.input():lines("l") or a specific iterator over given file @@ -32,7 +37,9 @@ int io_open(lua_State *L); * @lua@param ... * @exposed io.lines */ -int io_lines(lua_State *L); +sol::variadic_results io_lines(sol::this_state L, QString filename, + sol::variadic_args args); +sol::variadic_results io_lines_noargs(sol::this_state L); /** * Opens a file and sets it as default input or if given no arguments returns the default input. @@ -42,7 +49,9 @@ int io_lines(lua_State *L); * @lua@return nil|FILE* * @exposed io.input */ -int io_input(lua_State *L); +sol::variadic_results io_input_argless(sol::this_state L); +sol::variadic_results io_input_file(sol::this_state L, sol::userdata file); +sol::variadic_results io_input_name(sol::this_state L, QString filename); /** * Opens a file and sets it as default output or if given no arguments returns the default output @@ -52,7 +61,9 @@ int io_input(lua_State *L); * @lua@return nil|FILE* * @exposed io.output */ -int io_output(lua_State *L); +sol::variadic_results io_output_argless(sol::this_state L); +sol::variadic_results io_output_file(sol::this_state L, sol::userdata file); +sol::variadic_results io_output_name(sol::this_state L, QString filename); /** * Closes given file or io.output() if not given. @@ -61,7 +72,8 @@ int io_output(lua_State *L); * @lua@param nil|FILE* * @exposed io.close */ -int io_close(lua_State *L); +bool io_close_argless(sol::this_state L); +bool io_close_file(sol::this_state L, sol::userdata file); /** * Flushes the buffer for given file or io.output() if not given. @@ -70,7 +82,8 @@ int io_close(lua_State *L); * @lua@param nil|FILE* * @exposed io.flush */ -int io_flush(lua_State *L); +void io_flush_argless(sol::this_state L); +void io_flush_file(sol::this_state L, sol::userdata file); /** * Reads some data from the default input file @@ -79,7 +92,7 @@ int io_flush(lua_State *L); * @lua@param nil|string * @exposed io.read */ -int io_read(lua_State *L); +sol::variadic_results io_read(sol::this_state L, sol::variadic_args args); /** * Writes some data to the default output file @@ -88,10 +101,10 @@ int io_read(lua_State *L); * @lua@param nil|string * @exposed io.write */ -int io_write(lua_State *L); +sol::variadic_results io_write(sol::this_state L, sol::variadic_args args); -int io_popen(lua_State *L); -int io_tmpfile(lua_State *L); +void io_popen(); +void io_tmpfile(); // NOLINTEND(readability-identifier-naming) } // namespace chatterino::lua::api diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index dd0fa26ff..19ccd60fe 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -22,6 +22,7 @@ class ScrollbarHighlight; struct Message; using MessagePtr = std::shared_ptr; +using MessagePtrMut = std::shared_ptr; struct Message { Message(); ~Message(); diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index fca5753a8..db479adf3 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -42,6 +42,7 @@ #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" #include "util/QStringHash.hpp" +#include "util/Variant.hpp" #include "widgets/Window.hpp" #include @@ -52,6 +53,7 @@ #include #include +#include #include #include @@ -148,8 +150,7 @@ QUrl getFallbackHighlightSound() } void actuallyTriggerHighlights(const QString &channelName, bool playSound, - const std::optional &customSoundUrl, - bool windowAlert) + const QUrl &customSoundUrl, bool windowAlert) { if (getApp()->getStreamerMode()->isEnabled() && getSettings()->streamerModeMuteMentions) @@ -170,13 +171,8 @@ void actuallyTriggerHighlights(const QString &channelName, bool playSound, if (playSound && resolveFocus) { - // TODO(C++23): optional or_else - QUrl soundUrl; - if (customSoundUrl) - { - soundUrl = *customSoundUrl; - } - else + QUrl soundUrl = customSoundUrl; + if (soundUrl.isEmpty()) { soundUrl = getFallbackHighlightSound(); } @@ -384,6 +380,97 @@ EmotePtr makeAutoModBadge() Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); } +std::tuple, MessageElementFlags, bool> parseEmote( + TwitchChannel *twitchChannel, const EmoteName &name) +{ + // Emote order: + // - FrankerFaceZ Channel + // - BetterTTV Channel + // - 7TV Channel + // - FrankerFaceZ Global + // - BetterTTV Global + // - 7TV Global + + const auto *globalFfzEmotes = getApp()->getFfzEmotes(); + const auto *globalBttvEmotes = getApp()->getBttvEmotes(); + const auto *globalSeventvEmotes = getApp()->getSeventvEmotes(); + + std::optional emote{}; + + if (twitchChannel != nullptr) + { + // Check for channel emotes + + emote = twitchChannel->ffzEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::FfzEmote, + false, + }; + } + + emote = twitchChannel->bttvEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::BttvEmote, + false, + }; + } + + emote = twitchChannel->seventvEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::SevenTVEmote, + emote.value()->zeroWidth, + }; + } + } + + // Check for global emotes + + emote = globalFfzEmotes->emote(name); + if (emote) + { + return { + emote, + MessageElementFlag::FfzEmote, + false, + }; + } + + emote = globalBttvEmotes->emote(name); + if (emote) + { + return { + emote, + MessageElementFlag::BttvEmote, + zeroWidthEmotes.contains(name.string), + }; + } + + emote = globalSeventvEmotes->globalEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::SevenTVEmote, + emote.value()->zeroWidth, + }; + } + + return { + {}, + {}, + false, + }; +} + } // namespace namespace chatterino { @@ -400,36 +487,6 @@ MessagePtr makeSystemMessage(const QString &text, const QTime &time) MessageBuilder::MessageBuilder() : message_(std::make_shared()) - , ircMessage(nullptr) -{ -} - -MessageBuilder::MessageBuilder(Channel *_channel, - const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args) - : twitchChannel(dynamic_cast(_channel)) - , message_(std::make_shared()) - , channel(_channel) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(_ircMessage->content()) - , action_(_ircMessage->isAction()) -{ -} - -MessageBuilder::MessageBuilder(Channel *_channel, - const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, QString content, - bool isAction) - : twitchChannel(dynamic_cast(_channel)) - , message_(std::make_shared()) - , channel(_channel) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(content) - , action_(isAction) { } @@ -1013,14 +1070,14 @@ Message &MessageBuilder::message() return *this->message_; } -MessagePtr MessageBuilder::release() +MessagePtrMut MessageBuilder::release() { std::shared_ptr ptr; this->message_.swap(ptr); return ptr; } -std::weak_ptr MessageBuilder::weakOf() +std::weak_ptr MessageBuilder::weakOf() { return this->message_; } @@ -1072,221 +1129,36 @@ void MessageBuilder::addLink(const linkparser::Parsed &parsedLink, getApp()->getLinkResolver()->resolve(el->linkInfo()); } -bool MessageBuilder::isIgnored() const +bool MessageBuilder::isIgnored(const QString &originalMessage, + const QString &userID, const Channel *channel) { return isIgnoredMessage({ - /*.message = */ this->originalMessage_, - /*.twitchUserID = */ this->tags.value("user-id").toString(), - /*.isMod = */ this->channel->isMod(), - /*.isBroadcaster = */ this->channel->isBroadcaster(), + .message = originalMessage, + .twitchUserID = userID, + .isMod = channel->isMod(), + .isBroadcaster = channel->isBroadcaster(), }); } -bool MessageBuilder::isIgnoredReply() const +void MessageBuilder::triggerHighlights(const Channel *channel, + const HighlightAlert &alert) { - return isIgnoredMessage({ - /*.message = */ this->originalMessage_, - /*.twitchUserID = */ - this->tags.value("reply-parent-user-id").toString(), - /*.isMod = */ this->channel->isMod(), - /*.isBroadcaster = */ this->channel->isBroadcaster(), - }); -} - -void MessageBuilder::triggerHighlights() -{ - if (this->historicalMessage_) + if (!alert.windowAlert && !alert.playSound) { - // Do nothing. Highlights should not be triggered on historical messages. return; } - - actuallyTriggerHighlights(this->channel->getName(), this->highlightSound_, - this->highlightSoundCustomUrl_, - this->highlightAlert_); -} - -MessagePtr MessageBuilder::build() -{ - assert(this->ircMessage != nullptr); - assert(this->channel != nullptr); - - // PARSE - this->userId_ = this->ircMessage->tag("user-id").toString(); - - this->parse(); - - if (this->userName == this->channel->getName()) - { - this->senderIsBroadcaster = true; - } - - this->message().channelName = this->channel->getName(); - - this->parseMessageID(); - - this->parseRoomID(); - - // If it is a reward it has to be appended first - if (this->args.channelPointRewardId != "") - { - assert(this->twitchChannel != nullptr); - const auto &reward = this->twitchChannel->channelPointReward( - this->args.channelPointRewardId); - if (reward) - { - this->appendChannelPointRewardMessage( - *reward, this->channel->isMod(), - this->channel->isBroadcaster()); - } - } - - this->appendChannelName(); - - if (this->tags.contains("rm-deleted")) - { - this->message().flags.set(MessageFlag::Disabled); - } - - this->historicalMessage_ = this->tags.contains("historical"); - - if (this->tags.contains("msg-id") && - this->tags["msg-id"].toString().split(';').contains( - "highlighted-message")) - { - this->message().flags.set(MessageFlag::RedeemedHighlight); - } - - if (this->tags.contains("first-msg") && - this->tags["first-msg"].toString() == "1") - { - this->message().flags.set(MessageFlag::FirstMessage); - } - - if (this->tags.contains("pinned-chat-paid-amount")) - { - this->message().flags.set(MessageFlag::ElevatedMessage); - } - - if (this->tags.contains("bits")) - { - this->message().flags.set(MessageFlag::CheerMessage); - } - - // reply threads - this->parseThread(); - - // timestamp - this->message().serverReceivedTime = calculateMessageTime(this->ircMessage); - this->emplace(this->message().serverReceivedTime.time()); - - if (this->shouldAddModerationElements()) - { - this->emplace(); - } - - this->appendTwitchBadges(); - - this->appendChatterinoBadges(); - this->appendFfzBadges(); - this->appendSeventvBadges(); - - this->appendUsername(); - - // QString bits; - auto iterator = this->tags.find("bits"); - if (iterator != this->tags.end()) - { - this->hasBits_ = true; - this->bitsLeft = iterator.value().toInt(); - this->bits = iterator.value().toString(); - } - - // Twitch emotes - auto twitchEmotes = parseTwitchEmotes(this->tags, this->originalMessage_, - this->messageOffset_); - - // This runs through all ignored phrases and runs its replacements on this->originalMessage_ - processIgnorePhrases(*getSettings()->ignoredMessages.readOnly(), - this->originalMessage_, twitchEmotes); - - std::sort(twitchEmotes.begin(), twitchEmotes.end(), - [](const auto &a, const auto &b) { - return a.start < b.start; - }); - twitchEmotes.erase(std::unique(twitchEmotes.begin(), twitchEmotes.end(), - [](const auto &first, const auto &second) { - return first.start == second.start; - }), - twitchEmotes.end()); - - // words - QStringList splits = this->originalMessage_.split(' '); - - this->addWords(splits, twitchEmotes); - - QString stylizedUsername = stylizeUsername(this->userName, this->message()); - - this->message().messageText = this->originalMessage_; - this->message().searchText = - stylizedUsername + " " + this->message().localizedName + " " + - this->userName + ": " + this->originalMessage_ + " " + - this->message().searchText; - - // highlights - this->parseHighlights(); - - // highlighting incoming whispers if requested per setting - if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) - { - this->message().flags.set(MessageFlag::HighlightedWhisper, true); - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Whisper); - } - - if (this->thread_) - { - auto &img = getResources().buttons.replyThreadDark; - this->emplace( - Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, - MessageElementFlag::ReplyButton) - ->setLink({Link::ViewThread, this->thread_->rootId()}); - } - else - { - auto &img = getResources().buttons.replyDark; - this->emplace( - Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, - MessageElementFlag::ReplyButton) - ->setLink({Link::ReplyToMessage, this->message().id}); - } - - return this->release(); -} - -void MessageBuilder::setThread(std::shared_ptr thread) -{ - this->thread_ = std::move(thread); -} - -void MessageBuilder::setParent(MessagePtr parent) -{ - this->parent_ = std::move(parent); -} - -void MessageBuilder::setMessageOffset(int offset) -{ - this->messageOffset_ = offset; + actuallyTriggerHighlights(channel->getName(), alert.playSound, + alert.customSound, alert.windowAlert); } void MessageBuilder::appendChannelPointRewardMessage( const ChannelPointReward &reward, bool isMod, bool isBroadcaster) { if (isIgnoredMessage({ - /*.message = */ "", - /*.twitchUserID = */ reward.user.id, - /*.isMod = */ isMod, - /*.isBroadcaster = */ isBroadcaster, + .message = {}, + .twitchUserID = reward.user.id, + .isMod = isMod, + .isBroadcaster = isBroadcaster, })) { return; @@ -1339,7 +1211,10 @@ void MessageBuilder::appendChannelPointRewardMessage( textList.append({redeemed, reward.title, QString::number(reward.cost)}); this->message().messageText = textList.join(" "); this->message().searchText = textList.join(" "); - this->message().loginName = reward.user.login; + if (!reward.user.login.isEmpty()) + { + this->message().loginName = reward.user.login; + } this->message().reward = std::make_shared(reward); } @@ -1763,9 +1638,10 @@ std::pair MessageBuilder::makeAutomodMessage( {}, {}, action.target.login, action.message, message2->flags); if (highlighted) { - actuallyTriggerHighlights(channelName, highlightResult.playSound, - highlightResult.customSoundUrl, - highlightResult.alert); + actuallyTriggerHighlights( + channelName, highlightResult.playSound, + highlightResult.customSoundUrl.value_or(QUrl{}), + highlightResult.alert); } return std::make_pair(message1, message2); @@ -2022,16 +1898,217 @@ MessagePtr MessageBuilder::makeLowTrustUpdateMessage( return builder.release(); } -void MessageBuilder::addTextOrEmoji(EmotePtr emote) +std::pair MessageBuilder::makeIrcMessage( + /* mutable */ Channel *channel, const Communi::IrcMessage *ircMessage, + const MessageParseArgs &args, /* mutable */ QString content, + const QString::size_type messageOffset, + const std::shared_ptr &thread, const MessagePtr &parent) +{ + assert(ircMessage != nullptr); + assert(channel != nullptr); + + auto tags = ircMessage->tags(); + if (args.allowIgnore) + { + bool ignored = MessageBuilder::isIgnored( + content, tags.value("user-id").toString(), channel); + if (ignored) + { + return {}; + } + } + + auto *twitchChannel = dynamic_cast(channel); + + auto userID = tags.value("user-id").toString(); + + MessageBuilder builder; + builder.parseUsernameColor(tags, userID); + + if (args.isAction) + { + builder.textColor_ = builder.message_->usernameColor; + builder->flags.set(MessageFlag::Action); + } + + builder.parseUsername(ircMessage, twitchChannel, + args.trimSubscriberUsername); + + builder->flags.set(MessageFlag::Collapsed); + + bool senderIsBroadcaster = builder->loginName == channel->getName(); + + builder->channelName = channel->getName(); + + builder.parseMessageID(tags); + + MessageBuilder::parseRoomID(tags, twitchChannel); + twitchChannel = builder.parseSharedChatInfo(tags, twitchChannel); + + // If it is a reward it has to be appended first + if (!args.channelPointRewardId.isEmpty()) + { + assert(twitchChannel != nullptr); + auto reward = + twitchChannel->channelPointReward(args.channelPointRewardId); + if (reward) + { + builder.appendChannelPointRewardMessage(*reward, channel->isMod(), + channel->isBroadcaster()); + } + } + + builder.appendChannelName(channel); + + if (tags.contains("rm-deleted")) + { + builder->flags.set(MessageFlag::Disabled); + } + + if (tags.contains("msg-id") && + tags["msg-id"].toString().split(';').contains("highlighted-message")) + { + builder->flags.set(MessageFlag::RedeemedHighlight); + } + + if (tags.contains("first-msg") && tags["first-msg"].toString() == "1") + { + builder->flags.set(MessageFlag::FirstMessage); + } + + if (tags.contains("pinned-chat-paid-amount")) + { + builder->flags.set(MessageFlag::ElevatedMessage); + } + + if (tags.contains("bits")) + { + builder->flags.set(MessageFlag::CheerMessage); + } + + // reply threads + builder.parseThread(content, tags, channel, thread, parent); + + // timestamp + builder->serverReceivedTime = calculateMessageTime(ircMessage); + builder.emplace(builder->serverReceivedTime.time()); + + bool shouldAddModerationElements = [&] { + if (senderIsBroadcaster) + { + // You cannot timeout the broadcaster + return false; + } + + if (tags.value("user-type").toString() == "mod" && + !args.isStaffOrBroadcaster) + { + // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel + return false; + } + + return true; + }(); + if (shouldAddModerationElements) + { + builder.emplace(); + } + + builder.appendTwitchBadges(tags, twitchChannel); + + builder.appendChatterinoBadges(userID); + builder.appendFfzBadges(twitchChannel, userID); + builder.appendSeventvBadges(userID); + + builder.appendUsername(tags, args); + + TextState textState{.twitchChannel = twitchChannel}; + QString bits; + + auto iterator = tags.find("bits"); + if (iterator != tags.end()) + { + textState.hasBits = true; + textState.bitsLeft = iterator.value().toInt(); + bits = iterator.value().toString(); + } + + // Twitch emotes + auto twitchEmotes = + parseTwitchEmotes(tags, content, static_cast(messageOffset)); + + // This runs through all ignored phrases and runs its replacements on content + processIgnorePhrases(*getSettings()->ignoredMessages.readOnly(), content, + twitchEmotes); + + std::ranges::sort(twitchEmotes, [](const auto &a, const auto &b) { + return a.start < b.start; + }); + auto uniqueEmotes = std::ranges::unique( + twitchEmotes, [](const auto &first, const auto &second) { + return first.start == second.start; + }); + twitchEmotes.erase(uniqueEmotes.begin(), uniqueEmotes.end()); + + // words + QStringList splits = content.split(' '); + + builder.addWords(splits, twitchEmotes, textState); + + QString stylizedUsername = + stylizeUsername(builder->loginName, builder.message()); + + builder->messageText = content; + builder->searchText = stylizedUsername + " " + builder->localizedName + + " " + builder->loginName + ": " + content + " " + + builder->searchText; + + // highlights + HighlightAlert highlight = builder.parseHighlights(tags, content, args); + if (tags.contains("historical")) + { + highlight.playSound = false; + highlight.windowAlert = false; + } + + // highlighting incoming whispers if requested per setting + if (args.isReceivedWhisper && getSettings()->highlightInlineWhispers) + { + builder->flags.set(MessageFlag::HighlightedWhisper); + builder->highlightColor = + ColorProvider::instance().color(ColorType::Whisper); + } + + if (thread) + { + auto &img = getResources().buttons.replyThreadDark; + builder + .emplace(Image::fromResourcePixmap(img, 0.15), + 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ViewThread, thread->rootId()}); + } + else + { + auto &img = getResources().buttons.replyDark; + builder + .emplace(Image::fromResourcePixmap(img, 0.15), + 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ReplyToMessage, builder->id}); + } + + return {builder.release(), highlight}; +} + +void MessageBuilder::addEmoji(const EmotePtr &emote) { this->emplace(emote, MessageElementFlag::EmojiAll); } -void MessageBuilder::addTextOrEmoji(const QString &string_) +void MessageBuilder::addTextOrEmote(TextState &state, QString string) { - auto string = QString(string_); - - if (this->hasBits_ && this->tryParseCheermote(string)) + if (state.hasBits && this->tryAppendCheermote(state, string)) { // This string was parsed as a cheermote return; @@ -2042,7 +2119,7 @@ void MessageBuilder::addTextOrEmoji(const QString &string_) // Emote name: "forsenPuke" - if string in ignoredEmotes // Will match emote regardless of source (i.e. bttv, ffz) // Emote source + name: "bttv:nyanPls" - if (this->tryAppendEmote({string})) + if (this->tryAppendEmote(state.twitchChannel, {string})) { // Successfully appended an emote return; @@ -2067,10 +2144,10 @@ void MessageBuilder::addTextOrEmoji(const QString &string_) QString username = match.captured(1); auto originalTextColor = textColor; - if (this->twitchChannel != nullptr) + if (state.twitchChannel != nullptr) { if (auto userColor = - this->twitchChannel->getUserColor(username); + state.twitchChannel->getUserColor(username); userColor.isValid()) { textColor = userColor; @@ -2093,17 +2170,17 @@ void MessageBuilder::addTextOrEmoji(const QString &string_) } } - if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) + if (state.twitchChannel != nullptr && getSettings()->findAllUsernames) { auto match = allUsernamesMentionRegex.match(string); QString username = match.captured(1); if (match.hasMatch() && - this->twitchChannel->accessChatters()->contains(username)) + state.twitchChannel->accessChatters()->contains(username)) { auto originalTextColor = textColor; - if (auto userColor = this->twitchChannel->getUserColor(username); + if (auto userColor = state.twitchChannel->getUserColor(username); userColor.isValid()) { textColor = userColor; @@ -2155,37 +2232,24 @@ TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text, MessageColor::System); } -void MessageBuilder::parse() -{ - this->parseUsernameColor(); - - if (this->action_) - { - this->textColor_ = this->usernameColor_; - this->message().flags.set(MessageFlag::Action); - } - - this->parseUsername(); - - this->message().flags.set(MessageFlag::Collapsed); -} - -void MessageBuilder::parseUsernameColor() +void MessageBuilder::parseUsernameColor(const QVariantMap &tags, + const QString &userID) { const auto *userData = getApp()->getUserData(); assert(userData != nullptr); - if (const auto &user = userData->getUser(this->userId_)) + if (const auto &user = userData->getUser(userID)) { if (user->color) { this->usernameColor_ = user->color.value(); + this->message().usernameColor = this->usernameColor_; return; } } - const auto iterator = this->tags.find("color"); - if (iterator != this->tags.end()) + const auto iterator = tags.find("color"); + if (iterator != tags.end()) { if (const auto color = iterator.value().toString(); !color.isEmpty()) { @@ -2195,117 +2259,140 @@ void MessageBuilder::parseUsernameColor() } } - if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) + if (getSettings()->colorizeNicknames && tags.contains("user-id")) { - this->usernameColor_ = - getRandomColor(this->tags.value("user-id").toString()); + this->usernameColor_ = getRandomColor(tags.value("user-id").toString()); this->message().usernameColor = this->usernameColor_; } } -void MessageBuilder::parseUsername() +void MessageBuilder::parseUsername(const Communi::IrcMessage *ircMessage, + TwitchChannel *twitchChannel, + bool trimSubscriberUsername) { // username - this->userName = this->ircMessage->nick(); + auto userName = ircMessage->nick(); - this->message().loginName = this->userName; - - if (this->userName.isEmpty() || this->args.trimSubscriberUsername) + if (userName.isEmpty() || trimSubscriberUsername) { - this->userName = this->tags.value(QLatin1String("login")).toString(); + userName = ircMessage->tag("login").toString(); } - // display name - // auto displayNameVariant = this->tags.value("display-name"); - // if (displayNameVariant.isValid()) { - // this->userName = displayNameVariant.toString() + " (" + - // this->userName + ")"; - // } - - this->message().loginName = this->userName; - if (this->twitchChannel != nullptr) + this->message_->loginName = userName; + if (twitchChannel != nullptr) { - this->twitchChannel->setUserColor(this->userName, this->usernameColor_); + twitchChannel->setUserColor(userName, this->message_->usernameColor); } // Update current user color if this is our message auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); - if (this->ircMessage->nick() == currentUser->getUserName()) + if (ircMessage->nick() == currentUser->getUserName()) { - currentUser->setColor(this->usernameColor_); + currentUser->setColor(this->message_->usernameColor); } } -void MessageBuilder::parseMessageID() +void MessageBuilder::parseMessageID(const QVariantMap &tags) { - auto iterator = this->tags.find("id"); + auto iterator = tags.find("id"); - if (iterator != this->tags.end()) + if (iterator != tags.end()) { this->message().id = iterator.value().toString(); } } -void MessageBuilder::parseRoomID() +QString MessageBuilder::parseRoomID(const QVariantMap &tags, + TwitchChannel *twitchChannel) { - if (this->twitchChannel == nullptr) + if (twitchChannel == nullptr) { - return; + return {}; } - auto iterator = this->tags.find("room-id"); + auto iterator = tags.find("room-id"); - if (iterator != std::end(this->tags)) + if (iterator != std::end(tags)) { - this->roomID_ = iterator.value().toString(); - - if (this->twitchChannel->roomId().isEmpty()) + auto roomID = iterator->toString(); + if (twitchChannel->roomId() != roomID) { - this->twitchChannel->setRoomId(this->roomID_); - } - - if (auto it = this->tags.find("source-room-id"); it != this->tags.end()) - { - auto sourceRoom = it.value().toString(); - if (this->roomID_ != sourceRoom) + if (twitchChannel->roomId().isEmpty()) { - this->message().flags.set(MessageFlag::SharedMessage); + twitchChannel->setRoomId(roomID); + } + else + { + qCWarning(chatterinoTwitch) + << "The room-ID of the received message doesn't match the " + "room-ID of the channel - received:" + << roomID << "channel:" << twitchChannel->roomId(); + } + } + return roomID; + } - auto sourceChan = - getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom); - if (sourceChan && !sourceChan->isEmpty()) + return {}; +} + +TwitchChannel *MessageBuilder::parseSharedChatInfo(const QVariantMap &tags, + TwitchChannel *twitchChannel) +{ + if (!twitchChannel) + { + return twitchChannel; + } + + if (auto it = tags.find("source-room-id"); it != tags.end()) + { + auto sourceRoom = it.value().toString(); + if (twitchChannel->roomId() != sourceRoom) + { + this->message().flags.set(MessageFlag::SharedMessage); + + auto sourceChan = + getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom); + if (sourceChan && !sourceChan->isEmpty()) + { + // avoid duplicate pings + this->message().flags.set( + MessageFlag::DoNotTriggerNotification); + + auto *chan = dynamic_cast(sourceChan.get()); + if (chan) { - this->sourceChannel = - dynamic_cast(sourceChan.get()); - // avoid duplicate pings - this->message().flags.set( - MessageFlag::DoNotTriggerNotification); + return chan; } } } } + return twitchChannel; } -void MessageBuilder::parseThread() +void MessageBuilder::parseThread(const QString &messageContent, + const QVariantMap &tags, + const Channel *channel, + const std::shared_ptr &thread, + const MessagePtr &parent) { - if (this->thread_) + if (thread) { // set references - this->message().replyThread = this->thread_; - this->message().replyParent = this->parent_; - this->thread_->addToThread(this->weakOf()); + this->message().replyThread = thread; + this->message().replyParent = parent; + thread->addToThread(std::weak_ptr{this->message_}); // enable reply flag this->message().flags.set(MessageFlag::ReplyMessage); MessagePtr threadRoot; - if (!this->parent_) + if (!parent) { - threadRoot = this->thread_->root(); + threadRoot = thread->root(); } else { - threadRoot = this->parent_; + threadRoot = parent; } QString usernameText = @@ -2317,7 +2404,7 @@ void MessageBuilder::parseThread() this->emplace( "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall) - ->setLink({Link::ViewThread, this->thread_->rootId()}); + ->setLink({Link::ViewThread, thread->rootId()}); this->emplace( "@" + usernameText + @@ -2336,18 +2423,17 @@ void MessageBuilder::parseThread() MessageElementFlags({MessageElementFlag::RepliedMessage, MessageElementFlag::Text}), color, FontStyle::ChatMediumSmall) - ->setLink({Link::ViewThread, this->thread_->rootId()}); + ->setLink({Link::ViewThread, thread->rootId()}); } - else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) + else if (tags.find("reply-parent-msg-id") != tags.end()) { // Message is a reply but we couldn't find the original message. // Render the message using the additional reply tags - auto replyDisplayName = this->tags.find("reply-parent-display-name"); - auto replyBody = this->tags.find("reply-parent-msg-body"); + auto replyDisplayName = tags.find("reply-parent-display-name"); + auto replyBody = tags.find("reply-parent-msg-body"); - if (replyDisplayName != this->tags.end() && - replyBody != this->tags.end()) + if (replyDisplayName != tags.end() && replyBody != tags.end()) { QString body; @@ -2356,7 +2442,10 @@ void MessageBuilder::parseThread() "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall); - if (this->isIgnoredReply()) + bool ignored = MessageBuilder::isIgnored( + messageContent, tags.value("reply-parent-user-id").toString(), + channel); + if (ignored) { body = QString("[Blocked user]"); } @@ -2380,67 +2469,76 @@ void MessageBuilder::parseThread() } } -void MessageBuilder::parseHighlights() +HighlightAlert MessageBuilder::parseHighlights(const QVariantMap &tags, + const QString &originalMessage, + const MessageParseArgs &args) { if (getSettings()->isBlacklistedUser(this->message().loginName)) { // Do nothing. We ignore highlights from this user. - return; + return {}; } - auto badges = parseBadgeTag(this->tags); + auto badges = parseBadgeTag(tags); auto [highlighted, highlightResult] = getApp()->getHighlights()->check( - this->args, badges, this->message().loginName, this->originalMessage_, + args, badges, this->message().loginName, originalMessage, this->message().flags); if (!highlighted) { - return; + return {}; } // This message triggered one or more highlights, act upon the highlight result this->message().flags.set(MessageFlag::Highlighted); - this->highlightAlert_ = highlightResult.alert; - - this->highlightSound_ = highlightResult.playSound; - this->highlightSoundCustomUrl_ = highlightResult.customSoundUrl; - this->message().highlightColor = highlightResult.color; if (highlightResult.showInMentions) { this->message().flags.set(MessageFlag::ShowInMentions); } + + auto customSound = [&] { + if (highlightResult.customSoundUrl) + { + return *highlightResult.customSoundUrl; + } + return QUrl{}; + }(); + return { + .customSound = customSound, + .playSound = highlightResult.playSound, + .windowAlert = highlightResult.alert, + }; } -void MessageBuilder::appendChannelName() +void MessageBuilder::appendChannelName(const Channel *channel) { - QString channelName("#" + this->channel->getName()); - Link link(Link::JumpToChannel, this->channel->getName()); + QString channelName("#" + channel->getName()); + Link link(Link::JumpToChannel, channel->getName()); this->emplace(channelName, MessageElementFlag::ChannelName, MessageColor::System) ->setLink(link); } -void MessageBuilder::appendUsername() +void MessageBuilder::appendUsername(const QVariantMap &tags, + const MessageParseArgs &args) { auto *app = getApp(); - QString username = this->userName; - this->message().loginName = username; + QString username = this->message_->loginName; QString localizedName; - auto iterator = this->tags.find("display-name"); - if (iterator != this->tags.end()) + auto iterator = tags.find("display-name"); + if (iterator != tags.end()) { QString displayName = parseTagString(iterator.value().toString()).trimmed(); - if (QString::compare(displayName, this->userName, - Qt::CaseInsensitive) == 0) + if (QString::compare(displayName, username, Qt::CaseInsensitive) == 0) { username = displayName; @@ -2457,13 +2555,13 @@ void MessageBuilder::appendUsername() QString usernameText = stylizeUsername(username, this->message()); - if (this->args.isSentWhisper) + if (args.isSentWhisper) { // TODO(pajlada): Re-implement // userDisplayString += // IrcManager::instance().getUser().getUserName(); } - else if (this->args.isReceivedWhisper) + else if (args.isReceivedWhisper) { // Sender username this->emplace(usernameText, MessageElementFlag::Username, @@ -2488,7 +2586,7 @@ void MessageBuilder::appendUsername() } else { - if (!this->action_) + if (!args.isAction) { usernameText += ":"; } @@ -2500,157 +2598,53 @@ void MessageBuilder::appendUsername() } } -const TwitchChannel *MessageBuilder::getSourceChannel() const +Outcome MessageBuilder::tryAppendEmote(TwitchChannel *twitchChannel, + const EmoteName &name) { - if (this->sourceChannel != nullptr) + auto [emote, flags, zeroWidth] = parseEmote(twitchChannel, name); + + if (!emote) { - return this->sourceChannel; + return Failure; } - return this->twitchChannel; -} - -std::tuple, MessageElementFlags, bool> - MessageBuilder::parseEmote(const EmoteName &name) const -{ - // Emote order: - // - FrankerFaceZ Channel - // - BetterTTV Channel - // - 7TV Channel - // - FrankerFaceZ Global - // - BetterTTV Global - // - 7TV Global - - const auto *globalFfzEmotes = getApp()->getFfzEmotes(); - const auto *globalBttvEmotes = getApp()->getBttvEmotes(); - const auto *globalSeventvEmotes = getApp()->getSeventvEmotes(); - - const auto *sourceChannel = this->getSourceChannel(); - - std::optional emote{}; - - if (sourceChannel != nullptr) + if (zeroWidth && getSettings()->enableZeroWidthEmotes && !this->isEmpty()) { - // Check for channel emotes - - emote = sourceChannel->ffzEmote(name); - if (emote) + // Attempt to merge current zero-width emote into any previous emotes + auto *asEmote = dynamic_cast(&this->back()); + if (asEmote) { - return { - emote, - MessageElementFlag::FfzEmote, - false, - }; + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = { + {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; + this->emplace( + std::move(layers), baseEmoteElement->getFlags() | flags, + this->textColor_); + return Success; } - emote = sourceChannel->bttvEmote(name); - if (emote) + auto *asLayered = dynamic_cast(&this->back()); + if (asLayered) { - return { - emote, - MessageElementFlag::BttvEmote, - false, - }; + asLayered->addEmoteLayer({*emote, flags}); + asLayered->addFlags(flags); + return Success; } - emote = sourceChannel->seventvEmote(name); - if (emote) - { - return { - emote, - MessageElementFlag::SevenTVEmote, - emote.value()->zeroWidth, - }; - } + // No emote to merge with, just show as regular emote } - // Check for global emotes - - emote = globalFfzEmotes->emote(name); - if (emote) - { - return { - emote, - MessageElementFlag::FfzEmote, - false, - }; - } - - emote = globalBttvEmotes->emote(name); - if (emote) - { - return { - emote, - MessageElementFlag::BttvEmote, - zeroWidthEmotes.contains(name.string), - }; - } - - emote = globalSeventvEmotes->globalEmote(name); - if (emote) - { - return { - emote, - MessageElementFlag::SevenTVEmote, - emote.value()->zeroWidth, - }; - } - - return { - {}, - {}, - false, - }; -} - -Outcome MessageBuilder::tryAppendEmote(const EmoteName &name) -{ - const auto [emote, flags, zeroWidth] = this->parseEmote(name); - - if (emote) - { - if (zeroWidth && getSettings()->enableZeroWidthEmotes && - !this->isEmpty()) - { - // Attempt to merge current zero-width emote into any previous emotes - auto *asEmote = dynamic_cast(&this->back()); - if (asEmote) - { - // Make sure to access asEmote before taking ownership when releasing - auto baseEmote = asEmote->getEmote(); - // Need to remove EmoteElement and replace with LayeredEmoteElement - auto baseEmoteElement = this->releaseBack(); - - std::vector layers = { - {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; - this->emplace( - std::move(layers), baseEmoteElement->getFlags() | flags, - this->textColor_); - return Success; - } - - auto *asLayered = - dynamic_cast(&this->back()); - if (asLayered) - { - asLayered->addEmoteLayer({*emote, flags}); - asLayered->addFlags(flags); - return Success; - } - - // No emote to merge with, just show as regular emote - } - - this->emplace(*emote, flags, this->textColor_); - return Success; - } - - return Failure; + this->emplace(*emote, flags, this->textColor_); + return Success; } void MessageBuilder::addWords( const QStringList &words, - const std::vector &twitchEmotes) + const std::vector &twitchEmotes, TextState &state) { // cursor currently indicates what character index we're currently operating in the full list of words int cursor = 0; @@ -2700,14 +2694,19 @@ void MessageBuilder::addWords( // 1. Add text before the emote QString preText = word.left(currentTwitchEmote.start - cursor); - for (auto &variant : + for (auto variant : getApp()->getEmotes()->getEmojis()->parse(preText)) { - boost::apply_visitor( - [&](auto &&arg) { - this->addTextOrEmoji(arg); - }, - variant); + boost::apply_visitor(variant::Overloaded{ + [&](const EmotePtr &emote) { + this->addEmoji(emote); + }, + [&](QString text) { + this->addTextOrEmote( + state, std::move(text)); + }, + }, + variant); } cursor += preText.size(); @@ -2721,79 +2720,84 @@ void MessageBuilder::addWords( } // split words - for (auto &variant : getApp()->getEmotes()->getEmojis()->parse(word)) + for (auto variant : getApp()->getEmotes()->getEmojis()->parse(word)) { - boost::apply_visitor( - [&](auto &&arg) { - this->addTextOrEmoji(arg); - }, - variant); + boost::apply_visitor(variant::Overloaded{ + [&](const EmotePtr &emote) { + this->addEmoji(emote); + }, + [&](QString text) { + this->addTextOrEmote(state, + std::move(text)); + }, + }, + variant); } cursor += word.size() + 1; } } -void MessageBuilder::appendTwitchBadges() +void MessageBuilder::appendTwitchBadges(const QVariantMap &tags, + TwitchChannel *twitchChannel) { - if (this->twitchChannel == nullptr) + if (twitchChannel == nullptr) { return; } - auto badgeInfos = parseBadgeInfoTag(this->tags); - auto badges = parseBadgeTag(this->tags); - appendBadges(this, badges, badgeInfos, this->twitchChannel); + auto badgeInfos = parseBadgeInfoTag(tags); + auto badges = parseBadgeTag(tags); + appendBadges(this, badges, badgeInfos, twitchChannel); } -void MessageBuilder::appendChatterinoBadges() +void MessageBuilder::appendChatterinoBadges(const QString &userID) { - if (auto badge = getApp()->getChatterinoBadges()->getBadge({this->userId_})) + if (auto badge = getApp()->getChatterinoBadges()->getBadge({userID})) { this->emplace(*badge, MessageElementFlag::BadgeChatterino); } } -void MessageBuilder::appendFfzBadges() +void MessageBuilder::appendFfzBadges(TwitchChannel *twitchChannel, + const QString &userID) { - for (const auto &badge : - getApp()->getFfzBadges()->getUserBadges({this->userId_})) + for (const auto &badge : getApp()->getFfzBadges()->getUserBadges({userID})) { this->emplace( badge.emote, MessageElementFlag::BadgeFfz, badge.color); } - if (this->twitchChannel == nullptr) + if (twitchChannel == nullptr) { return; } - for (const auto &badge : - this->twitchChannel->ffzChannelBadges(this->userId_)) + for (const auto &badge : twitchChannel->ffzChannelBadges(userID)) { this->emplace( badge.emote, MessageElementFlag::BadgeFfz, badge.color); } } -void MessageBuilder::appendSeventvBadges() +void MessageBuilder::appendSeventvBadges(const QString &userID) { - if (auto badge = getApp()->getSeventvBadges()->getBadge({this->userId_})) + if (auto badge = getApp()->getSeventvBadges()->getBadge({userID})) { this->emplace(*badge, MessageElementFlag::BadgeSevenTV); } } -Outcome MessageBuilder::tryParseCheermote(const QString &string) +Outcome MessageBuilder::tryAppendCheermote(TextState &state, + const QString &string) { - if (this->bitsLeft == 0) + if (state.bitsLeft == 0) { return Failure; } - const auto *chan = this->getSourceChannel(); - auto cheerOpt = chan->cheerEmote(string); + auto cheerOpt = state.twitchChannel->cheerEmote(string); if (!cheerOpt) { @@ -2812,7 +2816,7 @@ Outcome MessageBuilder::tryParseCheermote(const QString &string) if (getSettings()->stackBits) { - if (this->bitsStacked) + if (state.bitsStacked) { return Success; } @@ -2830,25 +2834,25 @@ Outcome MessageBuilder::tryParseCheermote(const QString &string) } if (cheerEmote.color != QColor()) { - this->emplace(QString::number(this->bitsLeft), + this->emplace(QString::number(state.bitsLeft), MessageElementFlag::BitsAmount, cheerEmote.color); } - this->bitsStacked = true; + state.bitsStacked = true; return Success; } - if (this->bitsLeft >= cheerValue) + if (state.bitsLeft >= cheerValue) { - this->bitsLeft -= cheerValue; + state.bitsLeft -= cheerValue; } else { QString newString = string; newString.chop(QString::number(cheerValue).length()); - newString += QString::number(cheerValue - this->bitsLeft); + newString += QString::number(cheerValue - state.bitsLeft); - return tryParseCheermote(newString); + return this->tryAppendCheermote(state, newString); } if (cheerEmote.staticEmote) @@ -2873,22 +2877,4 @@ Outcome MessageBuilder::tryParseCheermote(const QString &string) return Success; } -bool MessageBuilder::shouldAddModerationElements() const -{ - if (this->senderIsBroadcaster) - { - // You cannot timeout the broadcaster - return false; - } - - if (this->tags.value("user-type").toString() == "mod" && - !this->args.isStaffOrBroadcaster) - { - // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel - return false; - } - - return true; -} - } // namespace chatterino diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index b353a8bde..45b65095d 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -14,8 +14,6 @@ #include #include -#include -#include #include #include @@ -31,6 +29,7 @@ struct AutomodUserAction; struct AutomodInfoAction; struct Message; using MessagePtr = std::shared_ptr; +using MessagePtrMut = std::shared_ptr; class MessageElement; class TextElement; @@ -68,6 +67,7 @@ struct LiveUpdatesUpdateEmoteSetMessageTag { struct ImageUploaderResultTag { }; +// NOLINTBEGIN(readability-identifier-naming) const SystemMessageTag systemMessage{}; const RaidEntryMessageTag raidEntryMessage{}; const TimeoutMessageTag timeoutMessage{}; @@ -79,6 +79,7 @@ const LiveUpdatesUpdateEmoteSetMessageTag liveUpdatesUpdateEmoteSetMessage{}; // This signifies that you want to construct a message containing the result of // a successful image upload. const ImageUploaderResultTag imageUploaderResultMessage{}; +// NOLINTEND(readability-identifier-naming) MessagePtr makeSystemMessage(const QString &text); MessagePtr makeSystemMessage(const QString &text, const QTime &time); @@ -90,26 +91,22 @@ struct MessageParseArgs { bool trimSubscriberUsername = false; bool isStaffOrBroadcaster = false; bool isSubscriptionMessage = false; + bool allowIgnore = true; + bool isAction = false; QString channelPointRewardId = ""; }; +struct HighlightAlert { + QUrl customSound; + bool playSound = false; + bool windowAlert = false; +}; class MessageBuilder { public: /// Build a message without a base IRC message. MessageBuilder(); - /// Build a message based on an incoming IRC PRIVMSG - explicit MessageBuilder(Channel *_channel, - const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args); - - /// Build a message based on an incoming IRC message (e.g. notice) - explicit MessageBuilder(Channel *_channel, - const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, QString content, - bool isAction); - MessageBuilder(SystemMessageTag, const QString &text, const QTime &time = QTime::currentTime()); MessageBuilder(RaidEntryMessageTag, const QString &text, @@ -157,17 +154,10 @@ public: ~MessageBuilder() = default; - QString userName; - - /// The Twitch Channel the message was received in - TwitchChannel *twitchChannel = nullptr; - /// The Twitch Channel the message was sent in, according to the Shared Chat feature - TwitchChannel *sourceChannel = nullptr; - Message *operator->(); Message &message(); - MessagePtr release(); - std::weak_ptr weakOf(); + MessagePtrMut release(); + std::weak_ptr weakOf(); void append(std::unique_ptr element); void addLink(const linkparser::Parsed &parsedLink, const QString &source); @@ -184,14 +174,8 @@ public: return pointer; } - [[nodiscard]] bool isIgnored() const; - bool isIgnoredReply() const; - void triggerHighlights(); - MessagePtr build(); - - void setThread(std::shared_ptr thread); - void setParent(MessagePtr parent); - void setMessageOffset(int offset); + static void triggerHighlights(const Channel *channel, + const HighlightAlert &alert); void appendChannelPointRewardMessage(const ChannelPointReward &reward, bool isMod, bool isBroadcaster); @@ -231,96 +215,124 @@ public: static MessagePtr makeLowTrustUpdateMessage( const PubSubLowTrustUsersMessage &action); -protected: - void addTextOrEmoji(EmotePtr emote); - void addTextOrEmoji(const QString &string_); + /// @brief Builds a message out of an `ircMessage`. + /// + /// Building a message won't cause highlights to be triggered. They will + /// only be parsed. To trigger highlights (play sound etc.), use + /// triggerHighlights(). + /// + /// @param channel The channel this message was sent to. Must not be + /// `nullptr`. + /// @param ircMessage The original message. This can be any message + /// (PRIVMSG, USERNOTICE, etc.). Its content is not + /// accessed through this parameter but through `content`, + /// as the content might be inside a tag (e.g. gifts in a + /// USERNOTICE). + /// @param args Arguments from parsing a chat message. + /// @param content The message text. This isn't always the entire text. In + /// replies, the leading mention can be cut off. + /// See `messageOffset`. + /// @param messageOffset Starting offset to be used on index-based + /// operations on `content` such as parsing emotes. + /// For example: + /// ircMessage = "@hi there" + /// content = "there" + /// messageOffset_ = 4 + /// The index 6 would resolve to 6 - 4 = 2 => 'e' + /// @param thread The reply thread this message is part of. If there's no + /// thread, this is an empty `shared_ptr`. + /// @param parent The direct parent this message is replying to. This does + /// not need to be the `thread`s root. If this message isn't + /// replying to anything, this is an empty `shared_ptr`. + /// + /// @returns The built message and a highlight result. If the message is + /// ignored (e.g. from a blocked user), then the returned pointer + /// will be en empty `shared_ptr`. + static std::pair makeIrcMessage( + Channel *channel, const Communi::IrcMessage *ircMessage, + const MessageParseArgs &args, QString content, + QString::size_type messageOffset, + const std::shared_ptr &thread = {}, + const MessagePtr &parent = {}); + +private: + struct TextState { + TwitchChannel *twitchChannel = nullptr; + bool hasBits = false; + bool bitsStacked = false; + int bitsLeft = 0; + }; + void addEmoji(const EmotePtr &emote); + void addTextOrEmote(TextState &state, QString string); + + Outcome tryAppendCheermote(TextState &state, const QString &string); + Outcome tryAppendEmote(TwitchChannel *twitchChannel, const EmoteName &name); bool isEmpty() const; MessageElement &back(); std::unique_ptr releaseBack(); - MessageColor textColor_ = MessageColor::Text; - // Helper method that emplaces some text stylized as system text // and then appends that text to the QString parameter "toUpdate". // Returns the TextElement that was emplaced. TextElement *emplaceSystemTextAndUpdate(const QString &text, QString &toUpdate); - std::shared_ptr message_; - void parse(); - void parseUsernameColor(); - void parseUsername(); - void parseMessageID(); - void parseRoomID(); + void parseUsernameColor(const QVariantMap &tags, const QString &userID); + void parseUsername(const Communi::IrcMessage *ircMessage, + TwitchChannel *twitchChannel, + bool trimSubscriberUsername); + void parseMessageID(const QVariantMap &tags); + + /// Parses the room-ID this message was received in + /// + /// @returns The room-ID + static QString parseRoomID(const QVariantMap &tags, + TwitchChannel *twitchChannel); + + /// Parses the shared-chat information from this message. + /// + /// @param tags The tags of the received message + /// @param twitchChannel The channel this message was received in + /// @returns The source channel - the channel this message originated from. + /// If there's no channel currently open, @a twitchChannel is + /// returned. + TwitchChannel *parseSharedChatInfo(const QVariantMap &tags, + TwitchChannel *twitchChannel); + // Parse & build thread information into the message // Will read information from thread_ or from IRC tags - void parseThread(); + void parseThread(const QString &messageContent, const QVariantMap &tags, + const Channel *channel, + const std::shared_ptr &thread, + const MessagePtr &parent); // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function - void parseHighlights(); - void appendChannelName(); - void appendUsername(); + HighlightAlert parseHighlights(const QVariantMap &tags, + const QString &originalMessage, + const MessageParseArgs &args); - /// Return the Twitch Channel this message originated from - /// - /// Useful to handle messages from the "Shared Chat" feature - /// - /// Can return nullptr - const TwitchChannel *getSourceChannel() const; - - std::tuple, MessageElementFlags, bool> parseEmote( - const EmoteName &name) const; - Outcome tryAppendEmote(const EmoteName &name); + void appendChannelName(const Channel *channel); + void appendUsername(const QVariantMap &tags, const MessageParseArgs &args); void addWords(const QStringList &words, - const std::vector &twitchEmotes); + const std::vector &twitchEmotes, + TextState &state); - void appendTwitchBadges(); - void appendChatterinoBadges(); - void appendFfzBadges(); - void appendSeventvBadges(); - Outcome tryParseCheermote(const QString &string); + void appendTwitchBadges(const QVariantMap &tags, + TwitchChannel *twitchChannel); + void appendChatterinoBadges(const QString &userID); + void appendFfzBadges(TwitchChannel *twitchChannel, const QString &userID); + void appendSeventvBadges(const QString &userID); - bool shouldAddModerationElements() const; + [[nodiscard]] static bool isIgnored(const QString &originalMessage, + const QString &userID, + const Channel *channel); - QString roomID_; - bool hasBits_ = false; - QString bits; - int bitsLeft{}; - bool bitsStacked = false; - bool historicalMessage_ = false; - std::shared_ptr thread_; - MessagePtr parent_; - - /** - * Starting offset to be used on index-based operations on `originalMessage_`. - * - * For example: - * originalMessage_ = "there" - * messageOffset_ = 4 - * (the irc message is "hey there") - * - * then the index 6 would resolve to 6 - 4 = 2 => 'e' - */ - int messageOffset_ = 0; - - QString userId_; - bool senderIsBroadcaster{}; - - Channel *channel = nullptr; - const Communi::IrcMessage *ircMessage; - MessageParseArgs args; - const QVariantMap tags; - QString originalMessage_; - - const bool action_{}; + std::shared_ptr message_; + MessageColor textColor_ = MessageColor::Text; QColor usernameColor_ = {153, 153, 153}; - - bool highlightAlert_ = false; - bool highlightSound_ = false; - std::optional highlightSoundCustomUrl_{}; }; } // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 7d5bb34a4..fb9d2fa13 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -508,15 +508,20 @@ std::vector parseUserNoticeMessage(Channel *channel, { MessageParseArgs args; args.trimSubscriberUsername = true; + args.allowIgnore = false; - MessageBuilder builder(channel, message, args, content, false); - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); - if (mirrored) + auto [built, highlight] = MessageBuilder::makeIrcMessage( + channel, message, args, content, 0); + if (built) { - builder->flags.set(MessageFlag::SharedMessage); + built->flags.set(MessageFlag::Subscription); + built->flags.unset(MessageFlag::Highlighted); + if (mirrored) + { + built->flags.set(MessageFlag::SharedMessage); + } + builtMessages.emplace_back(std::move(built)); } - builtMessages.emplace_back(builder.build()); } } @@ -661,12 +666,13 @@ std::vector parsePrivMessage(Channel *channel, std::vector builtMessages; MessageParseArgs args; - MessageBuilder builder(channel, message, args, message->content(), - message->isAction()); - if (!builder.isIgnored()) + args.isAction = message->isAction(); + auto [built, alert] = MessageBuilder::makeIrcMessage(channel, message, args, + message->content(), 0); + if (built) { - builtMessages.emplace_back(builder.build()); - builder.triggerHighlights(); + builtMessages.emplace_back(std::move(built)); + MessageBuilder::triggerHighlights(channel, alert); } return builtMessages; @@ -709,22 +715,21 @@ std::vector IrcMessageHandler::parseMessageWithReply( { args.channelPointRewardId = it.value().toString(); } - MessageBuilder builder(channel, message, args, content, - privMsg->isAction()); - builder.setMessageOffset(messageOffset); + args.isAction = privMsg->isAction(); auto replyCtx = getReplyContext(tc, message, otherLoaded); - builder.setThread(std::move(replyCtx.thread)); - builder.setParent(std::move(replyCtx.parent)); - if (replyCtx.highlight) - { - builder.message().flags.set(MessageFlag::SubscribedThread); - } + auto [built, alert] = MessageBuilder::makeIrcMessage( + channel, message, args, content, messageOffset, replyCtx.thread, + replyCtx.parent); - if (!builder.isIgnored()) + if (built) { - builtMessages.emplace_back(builder.build()); - builder.triggerHighlights(); + if (replyCtx.highlight) + { + built->flags.set(MessageFlag::SubscribedThread); + } + builtMessages.emplace_back(built); + MessageBuilder::triggerHighlights(channel, alert); } if (message->tags().contains(u"pinned-chat-paid-amount"_s)) @@ -1016,20 +1021,18 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) auto *c = getApp()->getTwitch()->getWhispersChannel().get(); - MessageBuilder builder(c, ircMessage, args, - unescapeZeroWidthJoiner(ircMessage->parameter(1)), - false); - - if (builder.isIgnored()) + auto [message, alert] = MessageBuilder::makeIrcMessage( + c, ircMessage, args, unescapeZeroWidthJoiner(ircMessage->parameter(1)), + 0); + if (!message) { return; } - builder->flags.set(MessageFlag::Whisper); - MessagePtr message = builder.build(); - builder.triggerHighlights(); + message->flags.set(MessageFlag::Whisper); + MessageBuilder::triggerHighlights(c, alert); - getApp()->getTwitch()->setLastUserThatWhisperedMe(builder.userName); + getApp()->getTwitch()->setLastUserThatWhisperedMe(message->loginName); if (message->flags.has(MessageFlag::ShowInMentions)) { @@ -1504,6 +1507,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, { args.isStaffOrBroadcaster = true; } + args.isAction = isAction; auto *channel = dynamic_cast(chan.get()); @@ -1605,24 +1609,22 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, } } - MessageBuilder builder(channel, message, args, content, isAction); - builder.setMessageOffset(messageOffset); + args.allowIgnore = !isSub; + auto [msg, alert] = MessageBuilder::makeIrcMessage( + channel, message, args, content, messageOffset, replyCtx.thread, + replyCtx.parent); - builder.setThread(std::move(replyCtx.thread)); - builder.setParent(std::move(replyCtx.parent)); - if (replyCtx.highlight) - { - builder.message().flags.set(MessageFlag::SubscribedThread); - } - - if (isSub || !builder.isIgnored()) + if (msg) { if (isSub) { - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); + msg->flags.set(MessageFlag::Subscription); + msg->flags.unset(MessageFlag::Highlighted); + } + if (replyCtx.highlight) + { + msg->flags.set(MessageFlag::SubscribedThread); } - auto msg = builder.build(); IrcMessageHandler::setSimilarityFlags(msg, chan); @@ -1630,7 +1632,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, (!getSettings()->hideSimilar && getSettings()->shownSimilarTriggerHighlights)) { - builder.triggerHighlights(); + MessageBuilder::triggerHighlights(channel, alert); } const auto highlighted = msg->flags.has(MessageFlag::Highlighted); diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 41eb53de8..484b52bbb 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -64,22 +64,55 @@ const int MAX_QUEUED_REDEMPTIONS = 16; class TwitchChannel final : public Channel, public ChannelChatters { public: + /** + * @lua@class StreamStatus + */ struct StreamStatus { + /** + * @lua@field live boolean + */ bool live = false; bool rerun = false; + /** + * @lua@field viewer_count number + */ unsigned viewerCount = 0; + /** + * @lua@field title string Stream title or last stream title + */ QString title; + /** + * @lua@field game_name string + */ QString game; + /** + * @lua@field game_id string + */ QString gameId; QString uptime; + /** + * @lua@field uptime number Seconds since the stream started. + */ int uptimeSeconds = 0; QString streamType; QString streamId; }; + /** + * @lua@class RoomModes + */ struct RoomModes { + /** + * @lua@field subscriber_only boolean + */ bool submode = false; + /** + * @lua@field unique_chat boolean You might know this as r9kbeta or robot9000. + */ bool r9k = 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 emoteOnly = false; /** @@ -88,6 +121,8 @@ public: * Special cases: * -1 = follower mode off * 0 = follower mode on, no time requirement + * + * @lua@field follower_only number? Time in minutes you need to follow to chat or nil. **/ int followerOnly = -1; @@ -95,6 +130,8 @@ public: * @brief Number of seconds required to wait before typing emotes * * 0 = slow mode off + * + * @lua@field slow_mode number? Time in seconds you need to wait before sending messages or nil. **/ int slowMode = 0; }; diff --git a/src/util/TypeName.hpp b/src/util/TypeName.hpp index 3e5c674e8..1f6066381 100644 --- a/src/util/TypeName.hpp +++ b/src/util/TypeName.hpp @@ -28,6 +28,15 @@ constexpr auto type_name() name.remove_prefix(prefix.size()); name.remove_suffix(suffix.size()); + if (name.starts_with("class ")) + { + name.remove_prefix(6); + } + if (name.starts_with("struct ")) + { + name.remove_prefix(7); + } + return name; } diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index c2ddc1160..1cca478f0 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -805,6 +805,32 @@ void SplitContainer::popup() window.show(); } +QString channelTypeToString(Channel::Type value) noexcept +{ + using Type = chatterino::Channel::Type; + switch (value) + { + default: + assert(false && "value cannot be serialized"); + return "never"; + + case Type::Twitch: + return "twitch"; + case Type::TwitchWhispers: + return "whispers"; + case Type::TwitchWatching: + return "watching"; + case Type::TwitchMentions: + return "mentions"; + case Type::TwitchLive: + return "live"; + case Type::TwitchAutomod: + return "automod"; + case Type::Misc: + return "misc"; + } +} + NodeDescriptor SplitContainer::buildDescriptorRecursively( const Node *currentNode) const { @@ -814,7 +840,7 @@ NodeDescriptor SplitContainer::buildDescriptorRecursively( currentNode->split_->getIndirectChannel().getType(); SplitNodeDescriptor result; - result.type_ = qmagicenum::enumNameString(channelType); + result.type_ = channelTypeToString(channelType); result.channelName_ = currentNode->split_->getChannel()->getName(); result.filters_ = currentNode->split_->getFilters(); return result; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a2cb5f1b..547f0e7c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -48,6 +48,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp ${CMAKE_CURRENT_LIST_DIR}/src/CancellationToken.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Plugins.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchIrc.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IgnoreController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp diff --git a/tests/snapshots/IrcMessageHandler/bad-emotes.json b/tests/snapshots/IrcMessageHandler/bad-emotes.json index 33314c1e8..86714b028 100644 --- a/tests/snapshots/IrcMessageHandler/bad-emotes.json +++ b/tests/snapshots/IrcMessageHandler/bad-emotes.json @@ -153,7 +153,7 @@ "searchText": "mm2pl mm2pl: Kappa ", "serverReceivedTime": "2022-09-03T10:31:42Z", "timeoutUser": "", - "usernameColor": "#ff000000" + "usernameColor": "#ffdaa521" } ] } diff --git a/tests/snapshots/IrcMessageHandler/bad-emotes2.json b/tests/snapshots/IrcMessageHandler/bad-emotes2.json index 4455bf818..fa8cb19e5 100644 --- a/tests/snapshots/IrcMessageHandler/bad-emotes2.json +++ b/tests/snapshots/IrcMessageHandler/bad-emotes2.json @@ -153,7 +153,7 @@ "searchText": "mm2pl mm2pl: Kappa ", "serverReceivedTime": "2022-09-03T10:31:42Z", "timeoutUser": "", - "usernameColor": "#ff000000" + "usernameColor": "#ffdaa521" } ] } diff --git a/tests/snapshots/IrcMessageHandler/emote.json b/tests/snapshots/IrcMessageHandler/emote.json index 5a9ec3fd2..cb8965388 100644 --- a/tests/snapshots/IrcMessageHandler/emote.json +++ b/tests/snapshots/IrcMessageHandler/emote.json @@ -153,7 +153,7 @@ "searchText": "mm2pl mm2pl: Keepo ", "serverReceivedTime": "2022-09-03T10:31:35Z", "timeoutUser": "", - "usernameColor": "#ff000000" + "usernameColor": "#ffdaa521" } ] } diff --git a/tests/snapshots/IrcMessageHandler/emotes.json b/tests/snapshots/IrcMessageHandler/emotes.json index 19d6bd7bf..e7e6d4a02 100644 --- a/tests/snapshots/IrcMessageHandler/emotes.json +++ b/tests/snapshots/IrcMessageHandler/emotes.json @@ -221,7 +221,7 @@ "searchText": "mm2pl mm2pl: Kappa Keepo PogChamp ", "serverReceivedTime": "2022-09-03T10:31:42Z", "timeoutUser": "", - "usernameColor": "#ff000000" + "usernameColor": "#ffdaa521" } ] } diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp index b0c728059..67cd48d23 100644 --- a/tests/src/Filters.cpp +++ b/tests/src/Filters.cpp @@ -285,9 +285,9 @@ TEST_F(FiltersF, TypingContextChecks) QString originalMessage = privmsg->content(); - MessageBuilder builder(&channel, privmsg, MessageParseArgs{}); + auto [msg, alert] = MessageBuilder::makeIrcMessage( + &channel, privmsg, MessageParseArgs{}, originalMessage, 0); - auto msg = builder.build(); EXPECT_NE(msg.get(), nullptr); auto contextMap = buildContextMap(msg, &channel); diff --git a/tests/src/NetworkHelpers.hpp b/tests/src/NetworkHelpers.hpp new file mode 100644 index 000000000..f55355e4a --- /dev/null +++ b/tests/src/NetworkHelpers.hpp @@ -0,0 +1,55 @@ +#pragma once +#include "Test.hpp" + +#include +namespace chatterino { + +#ifdef CHATTERINO_TEST_USE_PUBLIC_HTTPBIN +// Using our self-hosted version of httpbox https://github.com/kevinastone/httpbox +const char *const HTTPBIN_BASE_URL = "https://braize.pajlada.com/httpbox"; +#else +const char *const HTTPBIN_BASE_URL = "http://127.0.0.1:9051"; +#endif + +class RequestWaiter +{ +public: + void requestDone() + { + { + std::unique_lock lck(this->mutex_); + ASSERT_FALSE(this->requestDone_); + this->requestDone_ = true; + } + this->condition_.notify_one(); + } + + void waitForRequest() + { + using namespace std::chrono_literals; + + while (true) + { + { + std::unique_lock lck(this->mutex_); + bool done = this->condition_.wait_for(lck, 10ms, [this] { + return this->requestDone_; + }); + if (done) + { + break; + } + } + QCoreApplication::processEvents(QEventLoop::AllEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + ASSERT_TRUE(this->requestDone_); + } + +private: + std::mutex mutex_; + std::condition_variable condition_; + bool requestDone_ = false; +}; +} // namespace chatterino diff --git a/tests/src/NetworkRequest.cpp b/tests/src/NetworkRequest.cpp index ca723481e..44b7504c7 100644 --- a/tests/src/NetworkRequest.cpp +++ b/tests/src/NetworkRequest.cpp @@ -2,6 +2,7 @@ #include "common/network/NetworkManager.hpp" #include "common/network/NetworkResult.hpp" +#include "NetworkHelpers.hpp" #include "Test.hpp" #include @@ -10,14 +11,6 @@ using namespace chatterino; namespace { -#ifdef CHATTERINO_TEST_USE_PUBLIC_HTTPBIN -// Not using httpbin.org, since it can be really slow and cause timeouts. -// postman-echo has the same API. -const char *const HTTPBIN_BASE_URL = "https://postman-echo.com"; -#else -const char *const HTTPBIN_BASE_URL = "http://127.0.0.1:9051"; -#endif - QString getStatusURL(int code) { return QString("%1/status/%2").arg(HTTPBIN_BASE_URL).arg(code); @@ -28,46 +21,6 @@ QString getDelayURL(int delay) return QString("%1/delay/%2").arg(HTTPBIN_BASE_URL).arg(delay); } -class RequestWaiter -{ -public: - void requestDone() - { - { - std::unique_lock lck(this->mutex_); - ASSERT_FALSE(this->requestDone_); - this->requestDone_ = true; - } - this->condition_.notify_one(); - } - - void waitForRequest() - { - using namespace std::chrono_literals; - - while (true) - { - { - std::unique_lock lck(this->mutex_); - bool done = this->condition_.wait_for(lck, 10ms, [this] { - return this->requestDone_; - }); - if (done) - { - break; - } - } - QCoreApplication::processEvents(QEventLoop::AllEvents); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - } - } - -private: - std::mutex mutex_; - std::condition_variable condition_; - bool requestDone_ = false; -}; - } // namespace TEST(NetworkRequest, Success) diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp new file mode 100644 index 000000000..588d3c2ff --- /dev/null +++ b/tests/src/Plugins.cpp @@ -0,0 +1,641 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" +# include "common/Channel.hpp" +# include "common/network/NetworkCommon.hpp" +# include "controllers/commands/Command.hpp" // IWYU pragma: keep +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "controllers/plugins/PluginPermission.hpp" +# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "mocks/BaseApplication.hpp" +# include "mocks/Channel.hpp" +# include "mocks/Emotes.hpp" +# include "mocks/Logging.hpp" +# include "mocks/TwitchIrcServer.hpp" +# include "NetworkHelpers.hpp" +# include "singletons/Logging.hpp" +# include "Test.hpp" + +# include +# include +# include + +# include +# include +# include + +using namespace chatterino; +using chatterino::mock::MockChannel; + +namespace { + +const QString TEST_SETTINGS = R"( +{ + "plugins": { + "supportEnabled": true, + "enabledPlugins": [ + "test" + ] + } +} +)"; + +class MockTwitch : public mock::MockTwitchIrcServer +{ +public: + ChannelPtr mm2pl = std::make_shared("mm2pl"); + + ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) override + { + if (dirtyChannelName == "mm2pl") + { + return this->mm2pl; + } + return Channel::getEmpty(); + } + + std::shared_ptr getChannelOrEmptyByID( + const QString &channelID) override + { + if (channelID == "117691339") + { + return this->mm2pl; + } + return Channel::getEmpty(); + } +}; + +class MockApplication : public mock::BaseApplication +{ +public: + MockApplication() + : mock::BaseApplication(TEST_SETTINGS) + , plugins(this->paths_) + , commands(this->paths_) + { + } + + PluginController *getPlugins() override + { + return &this->plugins; + } + + CommandController *getCommands() override + { + return &this->commands; + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + mock::MockTwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + ILogging *getChatLogger() override + { + return &this->logging; + } + + PluginController plugins; + mock::EmptyLogging logging; + CommandController commands; + mock::Emotes emotes; + MockTwitch twitch; +}; + +} // namespace + +namespace chatterino { + +class PluginControllerAccess +{ +public: + static bool tryLoadFromDir(const QDir &pluginDir) + { + return getApp()->getPlugins()->tryLoadFromDir(pluginDir); + } + + static void openLibrariesFor(Plugin *plugin) + { + return PluginController::openLibrariesFor(plugin); + } + + static std::map> &plugins() + { + return getApp()->getPlugins()->plugins_; + } + + static lua_State *state(Plugin *pl) + { + return pl->state_; + } +}; + +} // namespace chatterino + +class PluginTest : public ::testing::Test +{ +protected: + void configure(std::vector permissions = {}) + { + this->app = std::make_unique(); + + auto &plugins = PluginControllerAccess::plugins(); + { + PluginMeta meta; + meta.name = "Test"; + meta.license = "MIT"; + meta.homepage = "https://github.com/Chatterino/chatterino2"; + meta.description = "Plugin for tests"; + meta.permissions = std::move(permissions); + + QDir plugindir = + QDir(app->paths_.pluginsDirectory).absoluteFilePath("test"); + + plugindir.mkpath("."); + auto temp = std::make_unique("test", luaL_newstate(), meta, + plugindir); + this->rawpl = temp.get(); + plugins.insert({"test", std::move(temp)}); + } + + // XXX: this skips PluginController::load() + PluginControllerAccess::openLibrariesFor(rawpl); + this->lua = new sol::state_view(PluginControllerAccess::state(rawpl)); + + this->channel = app->twitch.mm2pl; + this->rawpl->dataDirectory().mkpath("."); + } + + void TearDown() override + { + // perform safe destruction of the plugin + delete this->lua; + this->lua = nullptr; + PluginControllerAccess::plugins().clear(); + this->rawpl = nullptr; + this->app.reset(); + } + + Plugin *rawpl = nullptr; + std::unique_ptr app; + sol::state_view *lua; + ChannelPtr channel; +}; + +TEST_F(PluginTest, testCommands) +{ + configure(); + + lua->script(R"lua( + _G.called = false + _G.words = nil + _G.channel = nil + c2.register_command("/test", function(ctx) + _G.called = true + _G.words = ctx.words + _G.channel = ctx.channel + end) + )lua"); + + EXPECT_EQ(app->commands.pluginCommands(), QStringList{"/test"}); + app->commands.execCommand("/test with arguments", channel, false); + bool called = (*lua)["called"]; + EXPECT_EQ(called, true); + + EXPECT_NE((*lua)["words"], sol::nil); + { + sol::table tbl = (*lua)["words"]; + std::vector words; + for (auto &o : tbl) + { + words.push_back(o.second.as()); + } + EXPECT_EQ(words, + std::vector({"/test", "with", "arguments"})); + } + + sol::object chnobj = (*lua)["channel"]; + EXPECT_EQ(chnobj.get_type(), sol::type::userdata); + lua::api::ChannelRef ref = chnobj.as(); + EXPECT_EQ(ref.get_name(), channel->getName()); +} + +TEST_F(PluginTest, testCompletion) +{ + configure(); + + lua->script(R"lua( + _G.called = false + _G.query = nil + _G.full_text_content = nil + _G.cursor_position = nil + _G.is_first_word = nil + + c2.register_callback( + c2.EventType.CompletionRequested, + function(ev) + _G.called = true + _G.query = ev.query + _G.full_text_content = ev.full_text_content + _G.cursor_position = ev.cursor_position + _G.is_first_word = ev.is_first_word + if ev.query == "exclusive" then + return { + hide_others = true, + values = {"Completion1", "Completion2"} + } + end + return { + hide_others = false, + values = {"Completion"}, + } + end + ) + )lua"); + + bool done{}; + QStringList results; + std::tie(done, results) = + app->plugins.updateCustomCompletions("foo", "foo", 3, true); + ASSERT_EQ(done, false); + ASSERT_EQ(results, QStringList{"Completion"}); + + ASSERT_EQ((*lua).get("query"), "foo"); + ASSERT_EQ((*lua).get("full_text_content"), "foo"); + ASSERT_EQ((*lua).get("cursor_position"), 3); + ASSERT_EQ((*lua).get("is_first_word"), true); + + std::tie(done, results) = app->plugins.updateCustomCompletions( + "exclusive", "foo exclusive", 13, false); + ASSERT_EQ(done, true); + ASSERT_EQ(results, QStringList({"Completion1", "Completion2"})); + + ASSERT_EQ((*lua).get("query"), "exclusive"); + ASSERT_EQ((*lua).get("full_text_content"), "foo exclusive"); + ASSERT_EQ((*lua).get("cursor_position"), 13); + ASSERT_EQ((*lua).get("is_first_word"), false); +} + +TEST_F(PluginTest, testChannel) +{ + configure(); + lua->script(R"lua( + chn = c2.Channel.by_name("mm2pl") + )lua"); + + ASSERT_EQ(lua->script(R"lua( return chn:get_name() )lua").get(0), + "mm2pl"); + ASSERT_EQ( + lua->script(R"lua( return chn:get_type() )lua").get(0), + Channel::Type::Twitch); + ASSERT_EQ( + lua->script(R"lua( return chn:get_display_name() )lua").get(0), + "mm2pl"); + // TODO: send_message, add_system_message + + ASSERT_EQ( + lua->script(R"lua( return chn:is_twitch_channel() )lua").get(0), + true); + + // this is not a TwitchChannel + const auto *shouldThrow1 = R"lua( + return chn:is_broadcaster() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + const auto *shouldThrow2 = R"lua( + return chn:is_mod() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); + const auto *shouldThrow3 = R"lua( + return chn:is_vip() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow3)); + const auto *shouldThrow4 = R"lua( + return chn:get_twitch_id() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow4)); +} + +TEST_F(PluginTest, testHttp) +{ + { + PluginPermission net; + net.type = PluginPermission::Type::Network; + configure({net}); + } + + lua->script(R"lua( + function DoReq(url, postdata) + r = c2.HTTPRequest.create(method, url) + r:on_success(function(res) + status = res:status() + data = res:data() + error = res:error() + success = true + end) + r:on_error(function(res) + status = res:status() + data = res:data() + error = res:error() + failure = true + end) + r:finally(function() + finally = true + done() + end) + if postdata ~= "" then + r:set_payload(postdata) + r:set_header("Content-Type", "text/plain") + end + r:set_timeout(1000) + r:execute() + end + )lua"); + + struct RequestCase { + QString url; + bool success; + bool failure; + + int status; + QString error; + + NetworkRequestType meth = NetworkRequestType::Get; + QByteArray data; // null means do not check + }; + + std::vector cases{ + {"/status/200", true, false, 200, "200"}, + {"/delay/2", false, true, 0, "TimeoutError"}, + {"/post", true, false, 200, "200", NetworkRequestType::Post, + "Example data"}, + }; + + for (const auto &c : cases) + { + lua->script(R"lua( + success = false + failure = false + finally = false + + status = nil + data = nil + error = nil + )lua"); + RequestWaiter waiter; + (*lua)["method"] = c.meth; + (*lua)["done"] = [&waiter]() { + waiter.requestDone(); + }; + + (*lua)["DoReq"](HTTPBIN_BASE_URL + c.url, c.data); + waiter.waitForRequest(); + + EXPECT_EQ(lua->get("success"), c.success); + EXPECT_EQ(lua->get("failure"), c.failure); + EXPECT_EQ(lua->get("finally"), true); + + if (c.status != 0) + { + EXPECT_EQ(lua->get("status"), c.status); + } + else + { + EXPECT_EQ((*lua)["status"], sol::nil); + } + EXPECT_EQ(lua->get("error"), c.error); + if (!c.data.isNull()) + { + EXPECT_EQ(lua->get("data"), c.data); + } + } +} + +const QByteArray TEST_FILE_DATA = "Test file data\nWith a new line.\n"; + +TEST_F(PluginTest, ioTest) +{ + { + PluginPermission ioread; + PluginPermission iowrite; + ioread.type = PluginPermission::Type::FilesystemRead; + iowrite.type = PluginPermission::Type::FilesystemWrite; + configure({ioread, iowrite}); + } + + lua->set("TEST_DATA", TEST_FILE_DATA); + + lua->script(R"lua( + f, err = io.open("testfile", "w") + print(f, err) + f:write(TEST_DATA) + f:close() + + f, err = io.open("testfile", "r") + out = f:read("a") + f:close() + )lua"); + EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); + + lua->script(R"lua( + io.input("testfile") + out = io.read("a") + )lua"); + EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); + + const auto *shouldThrow1 = R"lua( + io.popen("/bin/sh", "-c", "notify-send \"This should not execute.\"") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + const auto *shouldThrow2 = R"lua( + io.tmpfile() + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); +} + +TEST_F(PluginTest, ioNoPerms) +{ + configure(); + auto file = rawpl->dataDirectory().filePath("testfile"); + QFile f(file); + f.open(QFile::WriteOnly); + f.write(TEST_FILE_DATA); + f.close(); + + EXPECT_EQ( + // clang-format off + lua->script(R"lua( + f, err = io.open("testfile", "r") + return err + )lua").get(0), + "Plugin does not have permissions to access given file." + // clang-format on + ); + + const auto *shouldThrow1 = R"lua( + io.input("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); + + EXPECT_EQ( + // clang-format off + lua->script(R"lua( + f, err = io.open("testfile", "w") + return err + )lua").get(0), + "Plugin does not have permissions to access given file." + // clang-format on + ); + + const auto *shouldThrow2 = R"lua( + io.output("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow2)); + + const auto *shouldThrow3 = R"lua( + io.lines("testfile") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow3)); +} + +TEST_F(PluginTest, requireNoData) +{ + { + PluginPermission ioread; + PluginPermission iowrite; + ioread.type = PluginPermission::Type::FilesystemRead; + iowrite.type = PluginPermission::Type::FilesystemWrite; + configure({ioread, iowrite}); + } + + auto file = rawpl->dataDirectory().filePath("thisiscode.lua"); + QFile f(file); + f.open(QFile::WriteOnly); + f.write(R"lua(print("Data was executed"))lua"); + f.close(); + + const auto *shouldThrow1 = R"lua( + require("data.thisiscode") + )lua"; + EXPECT_ANY_THROW(lua->script(shouldThrow1)); +} + +TEST_F(PluginTest, testTimerRec) +{ + configure(); + + RequestWaiter waiter; + lua->set("done", [&] { + waiter.requestDone(); + }); + + sol::protected_function fn = lua->script(R"lua( + local i = 0 + f = function() + i = i + 1 + c2.log(c2.LogLevel.Info, "cb", i) + if i < 1024 then + c2.later(f, 1) + else + done() + end + end + c2.later(f, 1) + )lua"); + waiter.waitForRequest(); +} + +TEST_F(PluginTest, tryCallTest) +{ + configure(); + lua->script(R"lua( + function return_table() + return { + a="b" + } + end + function return_nothing() + end + function return_nil() + return nil + end + function return_nothing_and_error() + error("I failed :)") + end + )lua"); + + using func = sol::protected_function; + + func returnTable = lua->get("return_table"); + func returnNil = lua->get("return_nil"); + func returnNothing = lua->get("return_nothing"); + func returnNothingAndError = lua->get("return_nothing_and_error"); + + // happy paths + { + auto res = lua::tryCall(returnTable); + EXPECT_TRUE(res.has_value()); + auto t = res.value(); + EXPECT_EQ(t.get("a"), "b"); + } + { + // valid void return + auto res = lua::tryCall(returnNil); + EXPECT_TRUE(res.has_value()); + } + { + // valid void return + auto res = lua::tryCall(returnNothing); + EXPECT_TRUE(res.has_value()); + } + { + auto res = lua::tryCall(returnNothingAndError); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), "[string \"...\"]:13: I failed :)"); + } + { + auto res = lua::tryCall>(returnNil); + EXPECT_TRUE(res.has_value()); // no error + auto opt = *res; + EXPECT_FALSE(opt.has_value()); // but also no false + } + + // unhappy paths + { + // wrong return type + auto res = lua::tryCall(returnTable); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but table was returned"); + } + { + // optional but bad return type + auto res = lua::tryCall>(returnTable); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), "Expected std::optional to be returned but " + "table was returned"); + } + { + // no return + auto res = lua::tryCall(returnNothing); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but none was returned"); + } + { + // nil return + auto res = lua::tryCall(returnNil); + EXPECT_FALSE(res.has_value()); + EXPECT_EQ(res.error(), + "Expected int to be returned but lua_nil was returned"); + } +} + +#endif