feat(plugins): add basic message construction (#5754)

This commit is contained in:
nerix
2025-07-31 15:36:39 +02:00
committed by GitHub
parent 7e7c4112d7
commit a4478748f7
29 changed files with 2070 additions and 36 deletions
+1
View File
@@ -10,6 +10,7 @@
- Minor: Added WebSocket API for plugins. (#6076, #6186, #6314, #6315) - Minor: Added WebSocket API for plugins. (#6076, #6186, #6314, #6315)
- Minor: Allow for themes to set transparent values for window background on Linux. (#6137) - Minor: Allow for themes to set transparent values for window background on Linux. (#6137)
- Minor: Popup overlay now only draws an outline when being interacted with. (#6140) - Minor: Popup overlay now only draws an outline when being interacted with. (#6140)
- Minor: Added basic message API to plugins. (#5754)
- Minor: Made filters searchable in the Settings dialog search bar. (#5890) - Minor: Made filters searchable in the Settings dialog search bar. (#5890)
- Minor: Updated emojis to Unicode 16.0. (#6155) - Minor: Updated emojis to Unicode 16.0. (#6155)
- Minor: Allow disabling of double-click tab renaming through setting. (#6163, #6184) - Minor: Allow disabling of double-click tab renaming through setting. (#6163, #6184)
+208
View File
@@ -57,6 +57,12 @@ declare namespace c2 {
add_system_message(message: string): void; add_system_message(message: string): void;
add_message(
message: Message,
context?: MessageContext,
override_flags?: MessageFlag | null
): void;
is_twitch_channel(): boolean; is_twitch_channel(): boolean;
get_room_modes(): RoomModes; get_room_modes(): RoomModes;
@@ -152,4 +158,206 @@ declare namespace c2 {
) => WebSocket; ) => WebSocket;
} }
var WebSocket: WebSocketConstructor; var WebSocket: WebSocketConstructor;
interface Message {
__dummy: void; // avoid being an empty interface
}
interface MessageConstructor {
new: (this: void, init: MessageInit) => Message;
}
var Message: MessageConstructor;
interface MessageInit {
flags?: MessageFlag;
id?: string;
parse_time?: number;
search_text?: string;
message_text?: string;
login_name?: string;
display_name?: string;
localized_name?: string;
user_id?: string;
channel_name?: string;
username_color?: string;
server_received_time?: number;
highlight_color?: string | null;
elements?: MessageElementInit[];
}
interface MessageElementInitBase {
tooltip?: string;
trailing_space?: boolean;
}
type MessageColor = "text" | "link" | "system" | string;
type MessageElementInit =
| TextElementInit
| SingleLineTextElementInit
| MentionElementInit
| TimestampElementInit
| TwitchModerationElementInit
| LinebreakElementInit
| ReplyCurveElementInit;
interface TextElementInit extends MessageElementInitBase {
type: "text";
text: string;
flags?: MessageElementFlag;
color?: MessageColor;
style?: FontStyle;
}
interface SingleLineTextElementInit extends MessageElementInitBase {
type: "single-line-text";
text: string;
flags?: MessageElementFlag;
color?: MessageColor;
style?: FontStyle;
}
interface MentionElementInit extends MessageElementInitBase {
type: "mention";
display_name: string;
login_name: string;
fallback_color: MessageColor;
user_color: MessageColor;
}
interface TimestampElementInit extends MessageElementInitBase {
type: "timestamp";
time?: number;
}
interface TwitchModerationElementInit extends MessageElementInitBase {
type: "twitch-moderation";
}
interface LinebreakElementInit extends MessageElementInitBase {
type: "linebreak";
flags?: MessageElementFlag;
}
interface ReplyCurveElementInit extends MessageElementInitBase {
type: "reply-curve";
}
enum MessageFlag {
None = 0,
System = 0,
Timeout = 0,
Highlighted = 0,
DoNotTriggerNotification = 0,
Centered = 0,
Disabled = 0,
DisableCompactEmotes = 0,
Collapsed = 0,
ConnectedMessage = 0,
DisconnectedMessage = 0,
Untimeout = 0,
PubSub = 0,
Subscription = 0,
DoNotLog = 0,
AutoMod = 0,
RecentMessage = 0,
Whisper = 0,
HighlightedWhisper = 0,
Debug = 0,
Similar = 0,
RedeemedHighlight = 0,
RedeemedChannelPointReward = 0,
ShowInMentions = 0,
FirstMessage = 0,
ReplyMessage = 0,
ElevatedMessage = 0,
SubscribedThread = 0,
CheerMessage = 0,
LiveUpdatesAdd = 0,
LiveUpdatesRemove = 0,
LiveUpdatesUpdate = 0,
AutoModOffendingMessageHeader = 0,
AutoModOffendingMessage = 0,
LowTrustUsers = 0,
RestrictedMessage = 0,
MonitoredMessage = 0,
Action = 0,
SharedMessage = 0,
AutoModBlockedTerm = 0,
ClearChat = 0,
EventSub = 0,
ModerationAction = 0,
InvalidReplyTarget = 0,
}
enum MessageElementFlag {
None = 0,
Misc = 0,
Text = 0,
Username = 0,
Timestamp = 0,
TwitchEmoteImage = 0,
TwitchEmoteText = 0,
TwitchEmote = 0,
BttvEmoteImage = 0,
BttvEmoteText = 0,
BttvEmote = 0,
ChannelPointReward = 0,
ChannelPointRewardImage = 0,
FfzEmoteImage = 0,
FfzEmoteText = 0,
FfzEmote = 0,
SevenTVEmoteImage = 0,
SevenTVEmoteText = 0,
SevenTVEmote = 0,
EmoteImages = 0,
EmoteText = 0,
BitsStatic = 0,
BitsAnimated = 0,
BadgeSharedChannel = 0,
BadgeGlobalAuthority = 0,
BadgePredictions = 0,
BadgeChannelAuthority = 0,
BadgeSubscription = 0,
BadgeVanity = 0,
BadgeChatterino = 0,
BadgeSevenTV = 0,
BadgeFfz = 0,
Badges = 0,
ChannelName = 0,
BitsAmount = 0,
ModeratorTools = 0,
EmojiImage = 0,
EmojiText = 0,
EmojiAll = 0,
AlwaysShow = 0,
Collapsed = 0,
Mention = 0,
LowercaseLinks = 0,
RepliedMessage = 0,
ReplyButton = 0,
Default = 0,
}
enum FontStyle {
Tiny,
ChatSmall,
ChatMediumSmall,
ChatMedium,
ChatMediumBold,
ChatMediumItalic,
ChatLarge,
ChatVeryLarge,
TimestampMedium,
UiMedium,
UiMediumBold,
UiTabs,
EndType,
ChatStart,
ChatEnd,
}
enum MessageContext {
Original,
Repost,
}
} }
+228
View File
@@ -112,6 +112,13 @@ function c2.Channel:send_message(message, execute_commands) end
---@param message string ---@param message string
function c2.Channel:add_system_message(message) end function c2.Channel:add_system_message(message) end
--- Adds a message client-side
---
---@param message c2.Message
---@param context? c2.MessageContext The context of the message being added
---@param override_flags? c2.MessageFlag|nil Flags to override the message's flags (some splits might filter for this)
function c2.Channel:add_message(message, context, override_flags) end
--- Returns true for twitch channels. --- Returns true for twitch channels.
--- Compares the channel Type. Note that enum values aren't guaranteed, just --- Compares the channel Type. Note that enum values aren't guaranteed, just
--- that they are equal to the exposed enum. --- that they are equal to the exposed enum.
@@ -257,6 +264,227 @@ function c2.HTTPRequest.create(method, url) end
-- End src/controllers/plugins/api/HTTPRequest.hpp -- End src/controllers/plugins/api/HTTPRequest.hpp
-- Begin src/controllers/plugins/api/Message.hpp
---A chat message
---@class c2.Message
c2.Message = {}
---A table to initialize a new message
---@class MessageInit
---@field flags? c2.MessageFlag Message flags (see `c2.MessageFlags`)
---@field id? string The (ideally unique) message ID
---@field parse_time? number Time the message was parsed (in milliseconds since epoch)
---@field search_text? string Text to that is compared when searching for messages
---@field message_text? string The message text (used for filters for example)
---@field login_name? string The login name of the sender
---@field display_name? string The display name of the sender
---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty)
---@field user_id? string The ID of the user who sent the message
---@field channel_name? string The name of the channel this message appeared in
---@field username_color? string The color of the username
---@field server_received_time? number The time the server received the message (in milliseconds since epoch)
---@field highlight_color? string|nil The color of the highlight (if any)
---@field elements? MessageElementInit[] The elements of the message
---A base table to initialize a new message element
---@class MessageElementInitBase
---@field tooltip? string Tooltip text
---@field trailing_space? boolean Whether to add a trailing space after the element (default: true)
---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text", "link", and "system" are special values that take the current theme into account
---A table to initialize a new message text element
---@class TextElementInit : MessageElementInitBase
---@field type "text" The type of the element
---@field text string The text of this element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---@field color? MessageColor The color of the text
---@field style? c2.FontStyle The font style of the text
---A table to initialize a new message single-line text element
---@class SingleLineTextElementInit : MessageElementInitBase
---@field type "single-line-text" The type of the element
---@field text string The text of this element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---@field color? MessageColor The color of the text
---@field style? c2.FontStyle The font style of the text
---A table to initialize a new mention element
---@class MentionElementInit : MessageElementInitBase
---@field type "mention" The type of the element
---@field display_name string The display name of the mentioned user
---@field login_name string The login name of the mentioned user
---@field fallback_color MessageColor The color of the element in case the "Colorize @usernames" is disabled
---@field user_color MessageColor The color of the element in case the "Colorize @usernames" is enabled
---A table to initialize a new timestamp element
---@class TimestampElementInit : MessageElementInitBase
---@field type "timestamp" The type of the element
---@field time number? The time of the timestamp (in milliseconds since epoch). If not provided, the current time is used.
---A table to initialize a new Twitch moderation element (all the custom moderation buttons)
---@class TwitchModerationElementInit : MessageElementInitBase
---@field type "twitch-moderation" The type of the element
---A table to initialize a new linebreak element
---@class LinebreakElementInit : MessageElementInitBase
---@field type "linebreak" The type of the element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---A table to initialize a new reply curve element
---@class ReplyCurveElementInit : MessageElementInitBase
---@field type "reply-curve" The type of the element
---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit
--- Creates a new message
---
---@param init MessageInit The message initialization table
---@return c2.Message msg The new message
function c2.Message.new(init) end
-- Begin src/singletons/Fonts.hpp
---@enum c2.FontStyle
c2.FontStyle = {
Tiny = {}, ---@type c2.FontStyle.Tiny
ChatSmall = {}, ---@type c2.FontStyle.ChatSmall
ChatMediumSmall = {}, ---@type c2.FontStyle.ChatMediumSmall
ChatMedium = {}, ---@type c2.FontStyle.ChatMedium
ChatMediumBold = {}, ---@type c2.FontStyle.ChatMediumBold
ChatMediumItalic = {}, ---@type c2.FontStyle.ChatMediumItalic
ChatLarge = {}, ---@type c2.FontStyle.ChatLarge
ChatVeryLarge = {}, ---@type c2.FontStyle.ChatVeryLarge
TimestampMedium = {}, ---@type c2.FontStyle.TimestampMedium
UiMedium = {}, ---@type c2.FontStyle.UiMedium
UiMediumBold = {}, ---@type c2.FontStyle.UiMediumBold
UiTabs = {}, ---@type c2.FontStyle.UiTabs
EndType = {}, ---@type c2.FontStyle.EndType
ChatStart = {}, ---@type c2.FontStyle.ChatStart
ChatEnd = {}, ---@type c2.FontStyle.ChatEnd
}
-- End src/singletons/Fonts.hpp
-- Begin src/messages/MessageElement.hpp
---@enum c2.MessageElementFlag
c2.MessageElementFlag = {
None = 0,
Misc = 0,
Text = 0,
Username = 0,
Timestamp = 0,
TwitchEmoteImage = 0,
TwitchEmoteText = 0,
TwitchEmote = 0,
BttvEmoteImage = 0,
BttvEmoteText = 0,
BttvEmote = 0,
ChannelPointReward = 0,
ChannelPointRewardImage = 0,
FfzEmoteImage = 0,
FfzEmoteText = 0,
FfzEmote = 0,
SevenTVEmoteImage = 0,
SevenTVEmoteText = 0,
SevenTVEmote = 0,
EmoteImages = 0,
EmoteText = 0,
BitsStatic = 0,
BitsAnimated = 0,
BadgeSharedChannel = 0,
BadgeGlobalAuthority = 0,
BadgePredictions = 0,
BadgeChannelAuthority = 0,
BadgeSubscription = 0,
BadgeVanity = 0,
BadgeChatterino = 0,
BadgeSevenTV = 0,
BadgeFfz = 0,
Badges = 0,
ChannelName = 0,
BitsAmount = 0,
ModeratorTools = 0,
EmojiImage = 0,
EmojiText = 0,
EmojiAll = 0,
AlwaysShow = 0,
Collapsed = 0,
Mention = 0,
LowercaseLinks = 0,
RepliedMessage = 0,
ReplyButton = 0,
Default = 0,
}
-- End src/messages/MessageElement.hpp
-- Begin src/messages/MessageFlag.hpp
---@enum c2.MessageFlag
c2.MessageFlag = {
None = 0,
System = 0,
Timeout = 0,
Highlighted = 0,
DoNotTriggerNotification = 0,
Centered = 0,
Disabled = 0,
DisableCompactEmotes = 0,
Collapsed = 0,
ConnectedMessage = 0,
DisconnectedMessage = 0,
Untimeout = 0,
PubSub = 0,
Subscription = 0,
DoNotLog = 0,
AutoMod = 0,
RecentMessage = 0,
Whisper = 0,
HighlightedWhisper = 0,
Debug = 0,
Similar = 0,
RedeemedHighlight = 0,
RedeemedChannelPointReward = 0,
ShowInMentions = 0,
FirstMessage = 0,
ReplyMessage = 0,
ElevatedMessage = 0,
SubscribedThread = 0,
CheerMessage = 0,
LiveUpdatesAdd = 0,
LiveUpdatesRemove = 0,
LiveUpdatesUpdate = 0,
AutoModOffendingMessageHeader = 0,
AutoModOffendingMessage = 0,
LowTrustUsers = 0,
RestrictedMessage = 0,
MonitoredMessage = 0,
Action = 0,
SharedMessage = 0,
AutoModBlockedTerm = 0,
ClearChat = 0,
EventSub = 0,
ModerationAction = 0,
InvalidReplyTarget = 0,
}
-- End src/messages/MessageFlag.hpp
-- Begin src/common/enums/MessageContext.hpp
---@enum c2.MessageContext
c2.MessageContext = {
Original = {}, ---@type c2.MessageContext.Original
Repost = {}, ---@type c2.MessageContext.Repost
}
-- End src/common/enums/MessageContext.hpp
-- End src/controllers/plugins/api/Message.hpp
-- Begin src/controllers/plugins/api/WebSocket.hpp -- Begin src/controllers/plugins/api/WebSocket.hpp
---@class c2.WebSocket ---@class c2.WebSocket
+54
View File
@@ -350,6 +350,24 @@ Example:
pajladas:add_system_message("Hello, world!") pajladas:add_system_message("Hello, world!")
``` ```
#### `Channel:add_message(message[, context[, override_flags]])`
Add a rich message to a channel. The message can be created with [`Message.new`](#messagenewdata).
Example:
```lua
channel:add_message(c2.Message.new({
id = "myplugin-1234",
elements = {
{
type = "text",
text = "Hello, World!",
}
}
}))
```
##### `Channel:is_twitch_channel()` ##### `Channel:is_twitch_channel()`
Returns `true` if the channel is a Twitch channel, that is its type name has Returns `true` if the channel is a Twitch channel, that is its type name has
@@ -503,6 +521,42 @@ request:execute()
-- ConnectionRefusedError -- ConnectionRefusedError
``` ```
#### `Message`
Allows creation of rich chat messages. This is currently limited but is expected to be expanded soon.
##### `Message.new(data)`
Creates a new message from a table. The message can be added to a channel using
[`Channel:add_message`](#channeladd_messagemessage-context-override_flags):
```lua
c2.register_command("/testing", function(ctx)
ctx.channel:add_message(c2.Message.new({
id = "myplugin-1234",
highlight_color = "#80ff0000",
flags = c2.MessageFlag.Highlighted,
elements = {
{
type = "text",
color = "link",
text = "Hover me!",
tooltip = "<h1>This is text from my plugin</h1>"
},
{
type = "mention",
display_name = "@User",
login_name = "twitchdev",
fallback_color = "text",
user_color = "blue",
}
}
}))
end)
```
The full range of options can be found in the typing files ([LuaLS](./plugin-meta.lua), [TypeScript](./chatterino.d.ts)).
### Input/Output API ### Input/Output API
These functions are wrappers for Lua's I/O library. Functions on file pointer These functions are wrappers for Lua's I/O library. Functions on file pointer
+95 -25
View File
@@ -27,6 +27,14 @@ Valid commands are:
@lua@class and @lua@field have special treatment when it comes to generation of spacing new lines @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines
Non-command lines of comments are written with a space after '---' Non-command lines of comments are written with a space after '---'
To insert larger fragments of documentation, comments like
/* @lua-fragment
---@class foo
...
*/
will be inserted as-is. These only work in the top-level scope. They don't work
for class bodies.
""" """
from io import TextIOWrapper from io import TextIOWrapper
@@ -55,10 +63,18 @@ def strip_line(line: str):
return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip() return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip()
def strip_comments(line: str):
return re.sub(r"//.*|/\*.*?\*/", "", line).strip()
def is_comment_start(line: str): def is_comment_start(line: str):
return line.startswith("/**") return line.startswith("/**")
def is_fragment_start(line: str):
return line.startswith("/* @lua-fragment")
def is_enum_class(line: str): def is_enum_class(line: str):
return line.startswith("enum class") return line.startswith("enum class")
@@ -96,20 +112,26 @@ class Reader:
return self.lines[self.line_idx - 1].strip() return self.lines[self.line_idx - 1].strip()
return None return None
def next_doc_comment(self) -> Optional[list[str]]: def next_item(self) -> list[str] | str | None:
"""Finds the next documentation comment or fragment and advances the cursor"""
# find the start
while (line := self.next_line()) is not None:
if is_comment_start(line):
return self._read_doc_comment(line)
elif is_fragment_start(line):
return self._read_fragment()
return None
def _read_doc_comment(self, starting_line: str) -> Optional[list[str]]:
"""Reads a documentation comment (/** ... */) and advances the cursor""" """Reads a documentation comment (/** ... */) and advances the cursor"""
lines = [] lines = []
# find the start line = starting_line
while (line := self.next_line()) is not None and not is_comment_start(line):
pass
if line is None:
return None
stripped = strip_line(line) stripped = strip_line(line)
if stripped: if stripped:
lines.append(stripped) lines.append(stripped)
if stripped.endswith("*/"): if line.endswith("*/"):
return lines if lines else None return lines if lines else None
while (line := self.next_line()) is not None: while (line := self.next_line()) is not None:
@@ -131,6 +153,17 @@ class Reader:
return lines if lines else None return lines if lines else None
def _read_fragment(self) -> Optional[str]:
"""Reads an inline fragment comment (/* @lua-fragment ... */) and advances the cursor"""
body = ""
while (line := self.next_line()) is not None:
if line.endswith("*/"):
break
body += "\n" + line
return body
def read_class_body(self) -> list[list[str]]: def read_class_body(self) -> list[list[str]]:
"""The reader must be at the first line of the class/struct body. All comments inside the class are returned.""" """The reader must be at the first line of the class/struct body. All comments inside the class are returned."""
items = [] items = []
@@ -143,7 +176,8 @@ class Reader:
nesting += line.count("{") - line.count("}") nesting += line.count("{") - line.count("}")
self.next_line() self.next_line()
continue continue
doc = self.next_doc_comment() doc = self.next_item()
assert not isinstance(doc, str), "Fragment inside class body found"
if not doc: if not doc:
break break
items.append(doc) items.append(doc)
@@ -153,6 +187,7 @@ class Reader:
"""The reader must be before an enum class definition (possibly with some comments before). It returns all variants.""" """The reader must be before an enum class definition (possibly with some comments before). It returns all variants."""
items = [] items = []
is_comment = False is_comment = False
waiting_for_end = False
while (line := self.peek_line()) is not None and not line.startswith("};"): while (line := self.peek_line()) is not None and not line.startswith("};"):
self.next_line() self.next_line()
if is_comment: if is_comment:
@@ -175,7 +210,16 @@ class Reader:
if line.startswith("enum class"): if line.startswith("enum class"):
continue continue
items.append(line.rstrip(",")) if waiting_for_end:
if line.endswith(","):
waiting_for_end = False
continue
m = re.match(r"^([\w_]+)", line)
if not m:
continue
items.append(m.group(1))
waiting_for_end = not strip_comments(line).endswith(",")
return items return items
@@ -214,6 +258,25 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper):
out.write(f"function {name}({lua_params}) end\n\n") out.write(f"function {name}({lua_params}) end\n\n")
def write_enum(
name: str,
description: str | None,
variants: list[str],
is_flags: bool,
out: TextIOWrapper,
):
if description:
out.write(f"--- {description}\n")
out.write(f"---@enum {name}\n")
out.write(f"{name} = {{\n")
def value(variant):
return "0," if is_flags else f"{{}}, ---@type {name}.{variant}"
out.write("\n".join([f" {variant} = {value(variant)}" for variant in variants]))
out.write("\n}\n\n")
def read_file(path: Path, out: TextIOWrapper): def read_file(path: Path, out: TextIOWrapper):
print("Reading", path.relative_to(repo_root)) print("Reading", path.relative_to(repo_root))
with path.open("r") as f: with path.open("r") as f:
@@ -221,9 +284,14 @@ def read_file(path: Path, out: TextIOWrapper):
reader = Reader(lines) reader = Reader(lines)
while reader.has_next(): while reader.has_next():
doc_comment = reader.next_doc_comment() doc_comment = reader.next_item()
if not doc_comment: if not doc_comment:
break break
elif isinstance(doc_comment, str):
out.write(doc_comment)
out.write("\n")
continue
header_comment = None header_comment = None
if not doc_comment[0].startswith("@"): if not doc_comment[0].startswith("@"):
if len(doc_comment) == 1: if len(doc_comment) == 1:
@@ -241,21 +309,23 @@ def read_file(path: Path, out: TextIOWrapper):
reader.line_no(), reader.line_no(),
f"Invalid enum exposure - one command expected, got {len(header)}", f"Invalid enum exposure - one command expected, got {len(header)}",
) )
name = header[0].split(" ", 1)[1] args = header[0].split(" ")[1:]
printmsg(path, reader.line_no(), f"enum {name}") if len(args) < 1 or len(args) > 2:
if header_comment: panic(
out.write(f"--- {header_comment}\n") path,
out.write(f"---@enum {name}\n") reader.line_no(),
out.write(f"{name} = {{\n") f"Invalid @exposeenum - expected 2 arguments, got {len(args)}",
out.write(
"\n".join(
[
f" {variant} = {{}}, ---@type {name}.{variant}"
for variant in reader.read_enum_variants()
]
) )
name = args[0]
is_flags = len(args) >= 2 and args[1] == "[flags]"
printmsg(path, reader.line_no(), f"enum {name}")
write_enum(
name,
header_comment,
reader.read_enum_variants(),
is_flags,
out,
) )
out.write("\n}\n\n")
continue continue
# class # class
@@ -322,7 +392,7 @@ def inline_command(path: Path, line: int, comment: str, out: TextIOWrapper):
"Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!", "Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!",
) )
elif comment.startswith("@lua@"): elif comment.startswith("@lua@"):
out.write(f'---{comment.replace("@lua", "", 1)}\n') out.write(f"---{comment.replace('@lua', '', 1)}\n")
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -250,6 +250,8 @@ set(SOURCE_FILES
controllers/plugins/api/HTTPResponse.hpp controllers/plugins/api/HTTPResponse.hpp
controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.cpp
controllers/plugins/api/IOWrapper.hpp controllers/plugins/api/IOWrapper.hpp
controllers/plugins/api/Message.cpp
controllers/plugins/api/Message.hpp
controllers/plugins/api/WebSocket.cpp controllers/plugins/api/WebSocket.cpp
controllers/plugins/api/WebSocket.hpp controllers/plugins/api/WebSocket.hpp
controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.cpp
+2
View File
@@ -2,6 +2,8 @@
namespace chatterino { namespace chatterino {
/** @exposeenum c2.MessageContext */
/// Context of the message being added to a channel /// Context of the message being added to a channel
enum class MessageContext { enum class MessageContext {
/// This message is the original /// This message is the original
+1
View File
@@ -85,6 +85,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev);
* @includefile controllers/plugins/api/ChannelRef.hpp * @includefile controllers/plugins/api/ChannelRef.hpp
* @includefile controllers/plugins/api/HTTPResponse.hpp * @includefile controllers/plugins/api/HTTPResponse.hpp
* @includefile controllers/plugins/api/HTTPRequest.hpp * @includefile controllers/plugins/api/HTTPRequest.hpp
* @includefile controllers/plugins/api/Message.hpp
* @includefile controllers/plugins/api/WebSocket.hpp * @includefile controllers/plugins/api/WebSocket.hpp
* @includefile common/network/NetworkCommon.hpp * @includefile common/network/NetworkCommon.hpp
*/ */
+5 -6
View File
@@ -111,19 +111,18 @@ public:
* *
* @returns Sol reference to the table * @returns Sol reference to the table
*/ */
template <typename T> template <typename T, T... Additional>
requires std::is_enum_v<T> requires std::is_enum_v<T>
sol::table createEnumTable(sol::state_view &lua) sol::table createEnumTable(sol::state_view &lua)
{ {
constexpr auto values = magic_enum::enum_values<T>(); constexpr auto values = magic_enum::enum_values<T>();
auto out = lua.create_table(0, values.size()); auto out = lua.create_table(0, values.size() + sizeof...(Additional));
for (const T v : values) for (const T v : values)
{ {
std::string_view name = magic_enum::enum_name<T>(v); out.raw_set(magic_enum::enum_name<T>(v), v);
std::string str(name);
out.raw_set(str, v);
} }
(out.raw_set(magic_enum::enum_name<Additional>(), Additional), ...);
return out; return out;
} }
@@ -11,11 +11,13 @@
# include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/api/HTTPRequest.hpp"
# include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/HTTPResponse.hpp"
# include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/api/IOWrapper.hpp"
# include "controllers/plugins/api/Message.hpp"
# include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/api/WebSocket.hpp"
# include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/LuaUtilities.hpp"
# include "controllers/plugins/SolTypes.hpp" # include "controllers/plugins/SolTypes.hpp"
# include "messages/MessageBuilder.hpp" # include "messages/MessageBuilder.hpp"
# include "messages/MessageElement.hpp"
# include "singletons/Paths.hpp" # include "singletons/Paths.hpp"
# include "singletons/Settings.hpp" # include "singletons/Settings.hpp"
@@ -222,10 +224,16 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin)
lua::api::HTTPResponse::createUserType(c2); lua::api::HTTPResponse::createUserType(c2);
lua::api::HTTPRequest::createUserType(c2); lua::api::HTTPRequest::createUserType(c2);
lua::api::WebSocket::createUserType(c2, plugin); lua::api::WebSocket::createUserType(c2, plugin);
lua::api::message::createUserType(c2);
c2["ChannelType"] = lua::createEnumTable<Channel::Type>(lua); c2["ChannelType"] = lua::createEnumTable<Channel::Type>(lua);
c2["HTTPMethod"] = lua::createEnumTable<NetworkRequestType>(lua); c2["HTTPMethod"] = lua::createEnumTable<NetworkRequestType>(lua);
c2["EventType"] = lua::createEnumTable<lua::api::EventType>(lua); c2["EventType"] = lua::createEnumTable<lua::api::EventType>(lua);
c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(lua); c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(lua);
c2["MessageFlag"] =
lua::createEnumTable<MessageFlag, MessageFlag::None>(lua);
c2["MessageElementFlag"] = lua::createEnumTable<MessageElementFlag>(lua);
c2["FontStyle"] = lua::createEnumTable<FontStyle>(lua);
c2["MessageContext"] = lua::createEnumTable<MessageContext>(lua);
sol::table io = g["io"]; sol::table io = g["io"];
io.set_function( io.set_function(
@@ -88,6 +88,32 @@ void ChannelRef::add_system_message(QString text)
this->strong()->addSystemMessage(text); this->strong()->addSystemMessage(text);
} }
void ChannelRef::add_message(std::shared_ptr<Message> &message,
sol::variadic_args va)
{
MessageContext ctx = [&] {
if (va.size() >= 1)
{
return va.get<MessageContext>();
}
return MessageContext::Original;
}();
auto overrideFlags = [&]() -> std::optional<MessageFlags> {
if (va.size() >= 2)
{
auto flags = va.get<std::optional<MessageFlag>>(1);
if (flags)
{
return MessageFlags{*flags};
}
return {};
}
return {};
}();
this->strong()->addMessage(message, ctx, overrideFlags);
}
bool ChannelRef::is_twitch_channel() bool ChannelRef::is_twitch_channel()
{ {
return this->strong()->isTwitchChannel(); return this->strong()->isTwitchChannel();
@@ -168,6 +194,7 @@ void ChannelRef::createUserType(sol::table &c2)
"get_display_name", &ChannelRef::get_display_name, "get_display_name", &ChannelRef::get_display_name,
"send_message", &ChannelRef::send_message, "send_message", &ChannelRef::send_message,
"add_system_message", &ChannelRef::add_system_message, "add_system_message", &ChannelRef::add_system_message,
"add_message", &ChannelRef::add_message,
"is_twitch_channel", &ChannelRef::is_twitch_channel, "is_twitch_channel", &ChannelRef::is_twitch_channel,
// TwitchChannel // TwitchChannel
@@ -71,6 +71,16 @@ public:
*/ */
void add_system_message(QString text); void add_system_message(QString text);
/**
* Adds a message client-side
*
* @lua@param message c2.Message
* @lua@param context? c2.MessageContext The context of the message being added
* @lua@param override_flags? c2.MessageFlag|nil Flags to override the message's flags (some splits might filter for this)
* @exposed c2.Channel:add_message
*/
void add_message(std::shared_ptr<Message> &message, sol::variadic_args va);
/** /**
* Returns true for twitch channels. * Returns true for twitch channels.
* Compares the channel Type. Note that enum values aren't guaranteed, just * Compares the channel Type. Note that enum values aren't guaranteed, just
+249
View File
@@ -0,0 +1,249 @@
#include "controllers/plugins/api/Message.hpp"
#include "Application.hpp"
#include "messages/MessageElement.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/SolTypes.hpp"
# include "messages/Message.hpp"
# include <sol/sol.hpp>
namespace {
using namespace chatterino;
QDateTime datetimeFromOffset(qint64 offset)
{
auto dt = QDateTime::fromMSecsSinceEpoch(offset);
# ifdef CHATTERINO_WITH_TESTS
if (getApp()->isTest())
{
return dt.toUTC();
}
# endif
return dt;
}
MessageColor tryMakeMessageColor(const QString &name,
MessageColor fallback = MessageColor::Text)
{
if (name.isEmpty())
{
return fallback;
}
if (name == u"text")
{
return MessageColor::Text;
}
if (name == u"link")
{
return MessageColor::Link;
}
if (name == u"system")
{
return MessageColor::System;
}
// custom
return QColor(name);
}
template <typename T>
T requiredGet(const sol::table &tbl, auto &&key)
{
auto v = tbl.get<sol::optional<T>>(std::forward<decltype(key)>(key));
if (!v)
{
throw std::runtime_error(std::string{"Missing required property: "} +
key);
}
return *std::move(v);
}
std::unique_ptr<TextElement> textElementFromTable(const sol::table &tbl)
{
return std::make_unique<TextElement>(
requiredGet<QString>(tbl, "text"),
tbl.get_or("flags", MessageElementFlag::Text),
tryMakeMessageColor(tbl.get_or("color", QString{})),
tbl.get_or("style", FontStyle::ChatMedium));
}
std::unique_ptr<SingleLineTextElement> singleLineTextElementFromTable(
const sol::table &tbl)
{
return std::make_unique<SingleLineTextElement>(
requiredGet<QString>(tbl, "text"),
tbl.get_or("flags", MessageElementFlag::Text),
tryMakeMessageColor(tbl.get_or("color", QString{})),
tbl.get_or("style", FontStyle::ChatMedium));
}
std::unique_ptr<MentionElement> mentionElementFromTable(const sol::table &tbl)
{
// no flags!
return std::make_unique<MentionElement>(
requiredGet<QString>(tbl, "display_name"),
requiredGet<QString>(tbl, "login_name"),
tryMakeMessageColor(requiredGet<QString>(tbl, "fallback_color")),
tryMakeMessageColor(requiredGet<QString>(tbl, "user_color")));
}
std::unique_ptr<TimestampElement> timestampElementFromTable(
const sol::table &tbl)
{
// no flags!
auto time = tbl.get<std::optional<qint64>>("time");
if (time)
{
return std::make_unique<TimestampElement>(
datetimeFromOffset(*time).time());
}
return std::make_unique<TimestampElement>();
}
std::unique_ptr<TwitchModerationElement> twitchModerationElementFromTable()
{
// no flags!
return std::make_unique<TwitchModerationElement>();
}
std::unique_ptr<LinebreakElement> linebreakElementFromTable(
const sol::table &tbl)
{
return std::make_unique<LinebreakElement>(
tbl.get_or("flags", MessageElementFlag::None));
}
std::unique_ptr<ReplyCurveElement> replyCurveElementFromTable()
{
// no flags!
return std::make_unique<ReplyCurveElement>();
}
std::unique_ptr<MessageElement> elementFromTable(const sol::table &tbl)
{
auto type = requiredGet<QString>(tbl, "type");
std::unique_ptr<MessageElement> el;
if (type == u"text")
{
el = textElementFromTable(tbl);
}
else if (type == u"single-line-text")
{
el = singleLineTextElementFromTable(tbl);
}
else if (type == u"mention")
{
el = mentionElementFromTable(tbl);
}
else if (type == u"timestamp")
{
el = timestampElementFromTable(tbl);
}
else if (type == u"twitch-moderation")
{
el = twitchModerationElementFromTable();
}
else if (type == u"linebreak")
{
el = linebreakElementFromTable(tbl);
}
else if (type == u"reply-curve")
{
el = replyCurveElementFromTable();
}
else
{
throw std::runtime_error("Invalid message type");
}
assert(el);
el->setTrailingSpace(tbl.get_or("trailing_space", true));
el->setTooltip(tbl.get_or("tooltip", QString{}));
return el;
}
std::shared_ptr<Message> messageFromTable(const sol::table &tbl)
{
auto msg = std::make_shared<Message>();
msg->flags = tbl.get_or("flags", MessageFlag::None);
// This takes a UTC offset (not the milliseconds since the start of the day)
auto parseTime = tbl.get<std::optional<qint64>>("parse_time");
if (parseTime)
{
msg->parseTime = datetimeFromOffset(*parseTime).time();
}
msg->id = tbl.get_or("id", QString{});
msg->searchText = tbl.get_or("search_text", QString{});
msg->messageText = tbl.get_or("message_text", QString{});
msg->loginName = tbl.get_or("login_name", QString{});
msg->displayName = tbl.get_or("display_name", QString{});
msg->localizedName = tbl.get_or("localized_name", QString{});
msg->userID = tbl.get_or("user_id", QString{});
// missing: timeoutUser
msg->channelName = tbl.get_or("channel_name", QString{});
auto usernameColor = tbl.get_or("username_color", QString{});
if (!usernameColor.isEmpty())
{
msg->usernameColor = QColor(usernameColor);
}
auto serverReceivedTime =
tbl.get<std::optional<qint64>>("server_received_time");
if (serverReceivedTime)
{
msg->serverReceivedTime = datetimeFromOffset(*serverReceivedTime);
}
// missing: badges
// missing: badgeInfos
// we construct a color on the fly here
auto highlightColor = tbl.get_or("highlight_color", QString{});
if (!highlightColor.isEmpty())
{
msg->highlightColor = std::make_shared<QColor>(highlightColor);
}
// missing: replyThread
// missing: replyParent
// missing: count
auto elements = tbl.get<std::optional<sol::table>>("elements");
if (elements)
{
auto size = elements->size();
for (size_t i = 1; i <= size; i++)
{
msg->elements.emplace_back(
elementFromTable(elements->get<sol::table>(i)));
}
}
// missing: reward
return msg;
}
} // namespace
namespace chatterino::lua::api::message {
void createUserType(sol::table &c2)
{
c2.new_usertype<Message>("Message",
sol::factories([](const sol::table &tbl) {
return messageFromTable(tbl);
}));
}
} // namespace chatterino::lua::api::message
#endif
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include "messages/Message.hpp"
# include <sol/forward.hpp>
namespace chatterino::lua::api::message {
/* @lua-fragment
---A chat message
---@class c2.Message
c2.Message = {}
---A table to initialize a new message
---@class MessageInit
---@field flags? c2.MessageFlag Message flags (see `c2.MessageFlags`)
---@field id? string The (ideally unique) message ID
---@field parse_time? number Time the message was parsed (in milliseconds since epoch)
---@field search_text? string Text to that is compared when searching for messages
---@field message_text? string The message text (used for filters for example)
---@field login_name? string The login name of the sender
---@field display_name? string The display name of the sender
---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty)
---@field user_id? string The ID of the user who sent the message
---@field channel_name? string The name of the channel this message appeared in
---@field username_color? string The color of the username
---@field server_received_time? number The time the server received the message (in milliseconds since epoch)
---@field highlight_color? string|nil The color of the highlight (if any)
---@field elements? MessageElementInit[] The elements of the message
---A base table to initialize a new message element
---@class MessageElementInitBase
---@field tooltip? string Tooltip text
---@field trailing_space? boolean Whether to add a trailing space after the element (default: true)
---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text", "link", and "system" are special values that take the current theme into account
---A table to initialize a new message text element
---@class TextElementInit : MessageElementInitBase
---@field type "text" The type of the element
---@field text string The text of this element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---@field color? MessageColor The color of the text
---@field style? c2.FontStyle The font style of the text
---A table to initialize a new message single-line text element
---@class SingleLineTextElementInit : MessageElementInitBase
---@field type "single-line-text" The type of the element
---@field text string The text of this element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---@field color? MessageColor The color of the text
---@field style? c2.FontStyle The font style of the text
---A table to initialize a new mention element
---@class MentionElementInit : MessageElementInitBase
---@field type "mention" The type of the element
---@field display_name string The display name of the mentioned user
---@field login_name string The login name of the mentioned user
---@field fallback_color MessageColor The color of the element in case the "Colorize @usernames" is disabled
---@field user_color MessageColor The color of the element in case the "Colorize @usernames" is enabled
---A table to initialize a new timestamp element
---@class TimestampElementInit : MessageElementInitBase
---@field type "timestamp" The type of the element
---@field time number? The time of the timestamp (in milliseconds since epoch). If not provided, the current time is used.
---A table to initialize a new Twitch moderation element (all the custom moderation buttons)
---@class TwitchModerationElementInit : MessageElementInitBase
---@field type "twitch-moderation" The type of the element
---A table to initialize a new linebreak element
---@class LinebreakElementInit : MessageElementInitBase
---@field type "linebreak" The type of the element
---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`)
---A table to initialize a new reply curve element
---@class ReplyCurveElementInit : MessageElementInitBase
---@field type "reply-curve" The type of the element
---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit
--- Creates a new message
---
---@param init MessageInit The message initialization table
---@return c2.Message msg The new message
function c2.Message.new(init) end
*/
/**
* @includefile singletons/Fonts.hpp
* @includefile messages/MessageElement.hpp
* @includefile messages/MessageFlag.hpp
* @includefile common/enums/MessageContext.hpp
*/
/// Creates the c2.Message user type
void createUserType(sol::table &c2);
} // namespace chatterino::lua::api::message
#endif
+1
View File
@@ -31,6 +31,7 @@ using ImagePtr = std::shared_ptr<Image>;
struct Emote; struct Emote;
using EmotePtr = std::shared_ptr<const Emote>; using EmotePtr = std::shared_ptr<const Emote>;
/** @exposeenum c2.MessageElementFlag [flags] */
enum class MessageElementFlag : int64_t { enum class MessageElementFlag : int64_t {
None = 0LL, None = 0LL,
Misc = (1LL << 0), Misc = (1LL << 0),
+1
View File
@@ -6,6 +6,7 @@
namespace chatterino { namespace chatterino {
/** @exposeenum c2.MessageFlag [flags] */
enum class MessageFlag : std::int64_t { enum class MessageFlag : std::int64_t {
None = 0LL, None = 0LL,
System = (1LL << 0), System = (1LL << 0),
+1
View File
@@ -14,6 +14,7 @@ namespace chatterino {
class Settings; class Settings;
class Paths; class Paths;
/** @exposeenum c2.FontStyle */
enum class FontStyle : uint8_t { enum class FontStyle : uint8_t {
Tiny, Tiny,
ChatSmall, ChatSmall,
+8 -2
View File
@@ -2131,10 +2131,9 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
element->getTooltip(), getTooltipScale(scale))); element->getTooltip(), getTooltipScale(scale)));
} }
} }
else else if (auto *linkElement = dynamic_cast<LinkElement *>(element))
{ {
auto thumbnailSize = getSettings()->thumbnailSize; auto thumbnailSize = getSettings()->thumbnailSize;
auto *linkElement = dynamic_cast<LinkElement *>(element);
if (linkElement) if (linkElement)
{ {
if (linkElement->linkInfo()->isPending()) if (linkElement->linkInfo()->isPending())
@@ -2145,6 +2144,13 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
this->setLinkInfoTooltip(linkElement->linkInfo()); this->setLinkInfoTooltip(linkElement->linkInfo());
} }
} }
else
{
this->tooltipWidget_->setOne(TooltipEntry{
.image = nullptr,
.text = element->getTooltip(),
});
}
this->tooltipWidget_->moveTo( this->tooltipWidget_->moveTo(
event->globalPosition().toPoint() + QPoint(16, 16), event->globalPosition().toPoint() + QPoint(16, 16),
@@ -0,0 +1,24 @@
{
"input": "msg = {}",
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,71 @@
{
"input": [
"msg = {elements={",
" { type = 'linebreak' },",
" { type = 'linebreak', flags = c2.MessageElementFlag.BttvEmoteImage },",
" { type = 'linebreak', tooltip = 't' },",
" { type = 'linebreak', trailing_space = false },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"flags": "",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "LinebreakElement"
},
{
"flags": "BttvEmoteImage",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "LinebreakElement"
},
{
"flags": "",
"link": {
"type": "None",
"value": ""
},
"tooltip": "t",
"trailingSpace": true,
"type": "LinebreakElement"
},
{
"flags": "",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": false,
"type": "LinebreakElement"
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,103 @@
{
"input": [
"msg = {elements={",
" { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green' },",
" { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', flags = c2.MessageElementFlag.BttvEmote },",
" { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', tooltip = 't' },",
" { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', trailing_space = false },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"color": "Text",
"fallbackColor": "#ffff0000",
"flags": "Text|Mention",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "MentionElement",
"userColor": "#ff008000",
"userLoginName": "l",
"words": [
"d"
]
},
{
"color": "Text",
"fallbackColor": "#ffff0000",
"flags": "Text|Mention",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "MentionElement",
"userColor": "#ff008000",
"userLoginName": "l",
"words": [
"d"
]
},
{
"color": "Text",
"fallbackColor": "#ffff0000",
"flags": "Text|Mention",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "t",
"trailingSpace": true,
"type": "MentionElement",
"userColor": "#ff008000",
"userLoginName": "l",
"words": [
"d"
]
},
{
"color": "Text",
"fallbackColor": "#ffff0000",
"flags": "Text|Mention",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": false,
"type": "MentionElement",
"userColor": "#ff008000",
"userLoginName": "l",
"words": [
"d"
]
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,60 @@
{
"input": [
"msg = {",
" flags = c2.MessageFlag.System | c2.MessageFlag.Disabled,",
" id = 'foo-bar',",
" parse_time = 420000,",
" search_text = 'search',",
" message_text = 'message',",
" login_name = 'login',",
" display_name = 'display',",
" localized_name = 'local',",
" user_id = 'user-id',",
" username_color = 'blue',",
" server_received_time = 1230000,",
" highlight_color = '#12345678',",
" channel_name = 'channel',",
" elements = {",
" { type = 'text', text = 'aliens walking' }",
" }",
"}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "channel",
"count": 1,
"displayName": "display",
"elements": [
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"aliens",
"walking"
]
}
],
"flags": "System|Disabled",
"highlightColor": "#12345678",
"id": "foo-bar",
"localizedName": "local",
"loginName": "login",
"messageText": "message",
"searchText": "search",
"serverReceivedTime": "1970-01-01T00:20:30Z",
"timeoutUser": "",
"userID": "user-id",
"usernameColor": "#ff0000ff"
}
}
@@ -0,0 +1,71 @@
{
"input": [
"msg = {elements={",
" { type = 'reply-curve' },",
" { type = 'reply-curve', flags = c2.MessageElementFlag.BttvEmote },",
" { type = 'reply-curve', tooltip = 't' },",
" { type = 'reply-curve', trailing_space = false },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"flags": "RepliedMessage",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "ReplyCurveElement"
},
{
"flags": "RepliedMessage",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "ReplyCurveElement"
},
{
"flags": "RepliedMessage",
"link": {
"type": "None",
"value": ""
},
"tooltip": "t",
"trailingSpace": true,
"type": "ReplyCurveElement"
},
{
"flags": "RepliedMessage",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": false,
"type": "ReplyCurveElement"
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,188 @@
{
"input": [
"msg = {elements={",
" { type = 'single-line-text', text = '' },",
" { type = 'single-line-text', text = 'foo' },",
" { type = 'single-line-text', text = 'foo bar' },",
" { type = 'single-line-text', text = 'foo\\nbar' },",
" { type = 'single-line-text', text = 'foo', flags = c2.MessageElementFlag.Text | c2.MessageElementFlag.Timestamp },",
" { type = 'single-line-text', text = 'foo', color = 'text' },",
" { type = 'single-line-text', text = 'foo', color = 'system' },",
" { type = 'single-line-text', text = 'foo', color = 'link' },",
" { type = 'single-line-text', text = 'foo', color = 'green' },",
" { type = 'single-line-text', text = 'foo', style = c2.FontStyle.ChatSmall },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
""
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo",
"bar"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo\nbar"
]
},
{
"color": "Text",
"flags": "Text|Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "System",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "Link",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "#ff008000",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatSmall",
"tooltip": "",
"trailingSpace": true,
"type": "SingleLineTextElement",
"words": [
"foo"
]
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,188 @@
{
"input": [
"msg = {elements={",
" { type = 'text', text = '' },",
" { type = 'text', text = 'foo' },",
" { type = 'text', text = 'foo bar' },",
" { type = 'text', text = 'foo\\nbar' },",
" { type = 'text', text = 'foo', flags = c2.MessageElementFlag.Text | c2.MessageElementFlag.Timestamp },",
" { type = 'text', text = 'foo', color = 'text' },",
" { type = 'text', text = 'foo', color = 'system' },",
" { type = 'text', text = 'foo', color = 'link' },",
" { type = 'text', text = 'foo', color = 'green' },",
" { type = 'text', text = 'foo', style = c2.FontStyle.ChatSmall },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
""
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo",
"bar"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo\nbar"
]
},
{
"color": "Text",
"flags": "Text|Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "System",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "Link",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "#ff008000",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
},
{
"color": "Text",
"flags": "Text",
"link": {
"type": "None",
"value": ""
},
"style": "ChatSmall",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"foo"
]
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,139 @@
{
"input": [
"msg = {elements={",
" { type = 'timestamp' },",
" { type = 'timestamp', time = 1230000, flags = c2.MessageElementFlag.BttvEmote },",
" { type = 'timestamp', tooltip = 't' },",
" { type = 'timestamp', trailing_space = false },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"element": {
"color": "System",
"flags": "Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "TimestampMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"0:00"
]
},
"flags": "Timestamp",
"format": "",
"link": {
"type": "None",
"value": ""
},
"time": "00:00:00",
"tooltip": "",
"trailingSpace": true,
"type": "TimestampElement"
},
{
"element": {
"color": "System",
"flags": "Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "TimestampMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"0:20"
]
},
"flags": "Timestamp",
"format": "",
"link": {
"type": "None",
"value": ""
},
"time": "00:20:30",
"tooltip": "",
"trailingSpace": true,
"type": "TimestampElement"
},
{
"element": {
"color": "System",
"flags": "Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "TimestampMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"0:00"
]
},
"flags": "Timestamp",
"format": "",
"link": {
"type": "None",
"value": ""
},
"time": "00:00:00",
"tooltip": "t",
"trailingSpace": true,
"type": "TimestampElement"
},
{
"element": {
"color": "System",
"flags": "Timestamp",
"link": {
"type": "None",
"value": ""
},
"style": "TimestampMedium",
"tooltip": "",
"trailingSpace": true,
"type": "TextElement",
"words": [
"0:00"
]
},
"flags": "Timestamp",
"format": "",
"link": {
"type": "None",
"value": ""
},
"time": "00:00:00",
"tooltip": "",
"trailingSpace": false,
"type": "TimestampElement"
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
@@ -0,0 +1,71 @@
{
"input": [
"msg = {elements={",
" { type = 'twitch-moderation' },",
" { type = 'twitch-moderation', flags = c2.MessageElementFlag.BttvEmote },",
" { type = 'twitch-moderation', tooltip = 't' },",
" { type = 'twitch-moderation', trailing_space = false },",
"}}"
],
"output": {
"badgeInfos": {
},
"badges": [
],
"channelName": "",
"count": 1,
"displayName": "",
"elements": [
{
"flags": "ModeratorTools",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "TwitchModerationElement"
},
{
"flags": "ModeratorTools",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": true,
"type": "TwitchModerationElement"
},
{
"flags": "ModeratorTools",
"link": {
"type": "None",
"value": ""
},
"tooltip": "t",
"trailingSpace": true,
"type": "TwitchModerationElement"
},
{
"flags": "ModeratorTools",
"link": {
"type": "None",
"value": ""
},
"tooltip": "",
"trailingSpace": false,
"type": "TwitchModerationElement"
}
],
"flags": "",
"id": "",
"localizedName": "",
"loginName": "",
"messageText": "",
"searchText": "",
"serverReceivedTime": "",
"timeoutUser": "",
"userID": "",
"usernameColor": "#ff000000"
}
}
+151 -1
View File
@@ -10,6 +10,8 @@
# include "controllers/plugins/PluginController.hpp" # include "controllers/plugins/PluginController.hpp"
# include "controllers/plugins/PluginPermission.hpp" # include "controllers/plugins/PluginPermission.hpp"
# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep # include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep
# include "lib/Snapshot.hpp"
# include "messages/Message.hpp"
# include "mocks/BaseApplication.hpp" # include "mocks/BaseApplication.hpp"
# include "mocks/Channel.hpp" # include "mocks/Channel.hpp"
# include "mocks/Emotes.hpp" # include "mocks/Emotes.hpp"
@@ -32,6 +34,8 @@ using chatterino::mock::MockChannel;
namespace { namespace {
constexpr bool UPDATE_SNAPSHOTS = false;
const QString TEST_SETTINGS = R"( const QString TEST_SETTINGS = R"(
{ {
"plugins": { "plugins": {
@@ -104,7 +108,7 @@ public:
} }
PluginController plugins; PluginController plugins;
mock::EmptyLogging logging; mock::Logging logging;
CommandController commands; CommandController commands;
mock::Emotes emotes; mock::Emotes emotes;
MockTwitch twitch; MockTwitch twitch;
@@ -861,4 +865,150 @@ TEST_F(PluginTest, testWebSocketUnsetFns)
waiter.waitForRequest(); waiter.waitForRequest();
} }
TEST_F(PluginTest, MessageElementFlag)
{
configure();
lua->script(R"lua(
values = {}
for k, v in pairs(c2.MessageElementFlag) do
table.insert(values, ("%s=0x%x"):format(k, v))
end
table.sort(values, function(a, b) return a:lower() < b:lower() end)
out = table.concat(values, ",")
)lua");
const char *VALUES = "AlwaysShow=0x2000000,"
"BadgeChannelAuthority=0x8000,"
"BadgeChatterino=0x40000,"
"BadgeFfz=0x80000,"
"BadgeGlobalAuthority=0x2000,"
"BadgePredictions=0x4000,"
"BadgeSevenTV=0x1000000000,"
"BadgeSharedChannel=0x2000000000,"
"BadgeSubscription=0x10000,"
"BadgeVanity=0x20000,"
"BitsAmount=0x200000,"
"BitsAnimated=0x1000,"
"BitsStatic=0x800,"
"BttvEmoteImage=0x40,"
"BttvEmoteText=0x80,"
"ChannelName=0x100000,"
"ChannelPointReward=0x100,"
"Collapsed=0x4000000,"
"EmojiImage=0x800000,"
"EmojiText=0x1000000,"
"FfzEmoteImage=0x200,"
"FfzEmoteText=0x400,"
"LowercaseLinks=0x20000000,"
"Mention=0x8000000,"
"Misc=0x1,"
"ModeratorTools=0x400000,"
"RepliedMessage=0x100000000,"
"ReplyButton=0x200000000,"
"SevenTVEmoteImage=0x400000000,"
"SevenTVEmoteText=0x800000000,"
"Text=0x2,"
"Timestamp=0x8,"
"TwitchEmoteImage=0x10,"
"TwitchEmoteText=0x20,"
"Username=0x4";
std::string got = (*lua)["out"];
ASSERT_EQ(got, VALUES);
}
TEST_F(PluginTest, ChannelAddMessage)
{
configure();
lua->script(R"lua(
function do_it(chan)
local Repost = c2.MessageContext.Repost
local Original = c2.MessageContext.Original
chan:add_message(c2.Message.new({ id = "1" }))
chan:add_message(c2.Message.new({ id = "2" }), Repost)
chan:add_message(c2.Message.new({ id = "3" }), Original, nil)
chan:add_message(c2.Message.new({ id = "4" }), Repost, c2.MessageFlag.DoNotLog)
chan:add_message(c2.Message.new({ id = "5" }), Original, c2.MessageFlag.DoNotLog)
chan:add_message(c2.Message.new({ id = "6" }), Original, c2.MessageFlag.System)
end
)lua");
auto chan = std::make_shared<MockChannel>("mock");
std::vector<MessagePtr> logged;
EXPECT_CALL(this->app->logging, addMessage)
.Times(3)
.WillRepeatedly(
[&](const auto &, const auto &msg, const auto &, const auto &) {
logged.emplace_back(msg);
});
std::vector<std::pair<MessagePtr, std::optional<MessageFlags>>> added;
std::ignore = chan->messageAppended.connect([&](auto &&...args) {
added.emplace_back(std::forward<decltype(args)>(args)...);
});
(*lua)["do_it"](lua::api::ChannelRef(chan));
ASSERT_EQ(added.size(), 6);
ASSERT_EQ(added[0].first->id, "1");
ASSERT_FALSE(added[0].second.has_value());
ASSERT_EQ(added[1].first->id, "2");
ASSERT_FALSE(added[1].second.has_value());
ASSERT_EQ(added[2].first->id, "3");
ASSERT_FALSE(added[2].second.has_value());
ASSERT_EQ(added[3].first->id, "4");
ASSERT_EQ(added[3].second, MessageFlags{MessageFlag::DoNotLog});
ASSERT_EQ(added[4].first->id, "5");
ASSERT_EQ(added[4].second, MessageFlags{MessageFlag::DoNotLog});
ASSERT_EQ(added[5].first->id, "6");
ASSERT_EQ(added[5].second, MessageFlags{MessageFlag::System});
ASSERT_EQ(logged.size(), 3);
ASSERT_EQ(added[0].first, logged[0]);
ASSERT_EQ(added[2].first, logged[1]);
ASSERT_EQ(added[5].first, logged[2]);
}
class PluginMessageConstructionTest
: public PluginTest,
public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginMessageConstructionTest, Run)
{
auto fixture = testlib::Snapshot::read("PluginMessageCtor", GetParam());
configure();
std::string script;
if (fixture->input().isArray())
{
for (auto line : fixture->input().toArray())
{
script += line.toString().toStdString() + '\n';
}
}
else
{
script = fixture->inputString().toStdString() + '\n';
}
script += "out = c2.Message.new(msg)";
lua->script(script);
Message *got = (*lua)["out"];
ASSERT_TRUE(fixture->run(got->toJson(), UPDATE_SNAPSHOTS));
}
INSTANTIATE_TEST_SUITE_P(
PluginMessageConstruction, PluginMessageConstructionTest,
testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor")));
// verify that all snapshots are included
TEST(PluginMessageConstructionTest, Integrity)
{
ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested
}
#endif #endif
+2 -2
View File
@@ -41,9 +41,9 @@ namespace chatterino::testlib {
/// ///
/// TEST_P(ExampleTest, Run) { /// TEST_P(ExampleTest, Run) {
/// auto fixture = testlib::Snapshot::read("category", GetParam()); /// auto fixture = testlib::Snapshot::read("category", GetParam());
/// auto output = functionToTest(fixture.input()); // or input{String,Utf8} /// auto output = functionToTest(fixture->input()); // or input{String,Utf8}
/// // if snapshots are supposed to be updated, this will write the output /// // if snapshots are supposed to be updated, this will write the output
/// ASSERT_TRUE(fixture.run(output, UPDATE_SNAPSHOTS)); /// ASSERT_TRUE(fixture->run(output, UPDATE_SNAPSHOTS));
/// } /// }
/// ///
/// INSTANTIATE_TEST_SUITE_P(ExampleInstance, ExampleTest, /// INSTANTIATE_TEST_SUITE_P(ExampleInstance, ExampleTest,