feat: add initial experimental Twitch Eventsub support (#5837)

Co-authored-by: nerix <nerixdev@outlook.de>
This commit is contained in:
pajlada
2025-02-02 17:03:24 +01:00
committed by GitHub
parent f9f7d47b43
commit 0f8a29fdb9
130 changed files with 19656 additions and 8 deletions
+5
View File
@@ -44,6 +44,11 @@ CheckOptions:
- key: readability-identifier-naming.FunctionIgnoredRegexp - key: readability-identifier-naming.FunctionIgnoredRegexp
value: ^(TEST|MOCK_METHOD)$ value: ^(TEST|MOCK_METHOD)$
- key: readability-identifier-naming.GlobalFunctionCase
value: camelBack
- key: readability-identifier-naming.GlobalFunctionIgnoredRegexp
value: ^(tag_invoke)$
- key: readability-identifier-naming.MemberCase - key: readability-identifier-naming.MemberCase
value: camelBack value: camelBack
- key: readability-identifier-naming.PrivateMemberIgnoredRegexp - key: readability-identifier-naming.PrivateMemberIgnoredRegexp
+3
View File
@@ -49,3 +49,6 @@
[submodule "lib/sol2"] [submodule "lib/sol2"]
path = lib/sol2 path = lib/sol2
url = https://github.com/ThePhD/sol2.git url = https://github.com/ThePhD/sol2.git
[submodule "lib/certify"]
path = lib/certify
url = https://github.com/Chatterino/certify
+1
View File
@@ -20,6 +20,7 @@
- Bugfix: Fixed announcements not showing up in mentions tab. (#5857) - Bugfix: Fixed announcements not showing up in mentions tab. (#5857)
- Bugfix: Fixed the reply button showing for inline whispers and announcements. (#5863) - Bugfix: Fixed the reply button showing for inline whispers and announcements. (#5863)
- Bugfix: Fixed suspicious user treatment update messages not being searchable. (#5865) - Bugfix: Fixed suspicious user treatment update messages not being searchable. (#5865)
- Dev: Add initial experimental EventSub support. (#5837)
- Dev: Highlight checks now use non-capturing groups for the boundaries. (#5784) - Dev: Highlight checks now use non-capturing groups for the boundaries. (#5784)
- Dev: Updated Conan dependencies. (#5776) - Dev: Updated Conan dependencies. (#5776)
- Dev: Disable QT keywords (i.e. `emit`, `slots`, and `signals`). (#5882) - Dev: Disable QT keywords (i.e. `emit`, `slots`, and `signals`). (#5882)
+3
View File
@@ -207,6 +207,7 @@ find_package(PajladaSignals REQUIRED)
find_package(LRUCache REQUIRED) find_package(LRUCache REQUIRED)
find_package(MagicEnum REQUIRED) find_package(MagicEnum REQUIRED)
find_package(Doxygen) find_package(Doxygen)
find_package(BoostCertify REQUIRED)
if (USE_SYSTEM_PAJLADA_SETTINGS) if (USE_SYSTEM_PAJLADA_SETTINGS)
find_package(PajladaSettings REQUIRED) find_package(PajladaSettings REQUIRED)
@@ -229,6 +230,8 @@ if (BUILD_WITH_CRASHPAD)
add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler") add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler")
endif() endif()
add_subdirectory(lib/twitch-eventsub-ws EXCLUDE_FROM_ALL)
# Used to provide a date of build in the About page (for nightly builds). Getting the actual time of # Used to provide a date of build in the About page (for nightly builds). Getting the actual time of
# compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. # compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898.
# For CI runs, however, the date of build file generation should be consistent with the date of # For CI runs, however, the date of build file generation should be consistent with the date of
+14
View File
@@ -0,0 +1,14 @@
include(FindPackageHandleStandardArgs)
find_path(BoostCertify_INCLUDE_DIR boost/certify/https_verification.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/certify/include)
find_package_handle_standard_args(BoostCertify DEFAULT_MSG BoostCertify_INCLUDE_DIR)
if (BoostCertify_FOUND)
add_library(BoostCertify INTERFACE IMPORTED)
set_target_properties(BoostCertify PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${BoostCertify_INCLUDE_DIR}"
)
endif ()
mark_as_advanced(BoostCertify_INCLUDE_DIR)
Submodule
+1
Submodule lib/certify added at a448a3915d
+1
View File
@@ -0,0 +1 @@
__pycache__/
+9
View File
@@ -0,0 +1,9 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
known_first_party=lib
known_typing=typing
use_parentheses=True
line_length=120
sections=FUTURE,TYPING,STDLIB,FIRSTPARTY,THIRDPARTY,LOCALFOLDER
+5
View File
@@ -0,0 +1,5 @@
.pytest_cache/
venv/
build/
compile_commands.json
+35
View File
@@ -0,0 +1,35 @@
cmake_minimum_required(VERSION 3.15)
cmake_policy(SET CMP0087 NEW) # evaluates generator expressions in `install(CODE/SCRIPT)`
cmake_policy(SET CMP0091 NEW) # select MSVC runtime library through `CMAKE_MSVC_RUNTIME_LIBRARY`
include(FeatureSummary)
list(APPEND CMAKE_MODULE_PATH
"${CMAKE_SOURCE_DIR}/cmake"
)
project(twitch-eventsub-ws VERSION 0.1.0)
# Find boost on the system
find_package(Boost REQUIRED OPTIONAL_COMPONENTS json)
# Find OpenSSL on the system
find_package(OpenSSL REQUIRED)
option(BUILD_WITH_QT6 "Build with Qt6" On)
if (BUILD_WITH_QT6)
set(MAJOR_QT_VERSION "6")
else()
set(MAJOR_QT_VERSION "5")
endif()
find_package(Qt${MAJOR_QT_VERSION} REQUIRED COMPONENTS Core)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_subdirectory(src)
include(cmake/GenerateJson.cmake)
generate_json_impls(SRC_TARGET twitch-eventsub-ws GEN_TARGET twitch-eventsub-ws-gen)
feature_summary(WHAT ALL)
+14
View File
@@ -0,0 +1,14 @@
# twitch-eventsub-ws
This library implements some of [Twitch's EventSub](https://dev.twitch.tv/docs/eventsub/) topics for use in [Chatterino 2](https://github.com/Chatterino/chatterino2)
Topic generation is done in the `ast` directory
The example can be built like this:
```sh
mkdir build
cd build
cmake ../example
cmake --build .
```
+5
View File
@@ -0,0 +1,5 @@
## TODO
- Default of `json_transform` should be `snake_case` since it will be used most
- The websocket connections need to die when user is changed
- Figure out if the websocket connections need to die when the user refreshed their token
+2
View File
@@ -0,0 +1,2 @@
/venv
/__pycache__
+36
View File
@@ -0,0 +1,36 @@
## Available scripts
- `generate-and-replace-dir.py`
Usage: `./generate-and-replace-dir.py <path-to-dir-containing-header-files>`
Will find all header files in that directory (it won't search recursively), and if they also have a matching source file, generate json deserialize definition & implementations for them, and replacing the markers in the given file.
- `get-builtin-include-dirs.py`
Usage: `./get-builtin-include-dirs.py`
Prints what builtin include dirs will be used for any of the other scripts.
- `generate.py`
Usage: `./generate.py <path-to-header-file>`
Generates definitions & implementations for the given header file and write them to temporary files.
- `replace-in-file.py`
Usage: `./replace-in-file.py <path-to-header-file> <path-to-source-file>`
Reads sdin for two file paths containing the definition & implementations.
Can be used in tandem with `generate.py`, but realistically it's only there because I used it at some point.
## Environment variables
The following environment variables can be configured to change the behaviours of this project
- `LLVM_PATH`
Will be used to change where to find the `clang++` executable file.
Example: `LLVM_PATH=/opt/llvm` ./get-builtin-include-dirs.py will use `/opt/llvm/bin/clang++`
- `LIBCLANG_LIBRARY_FILE`
Will be used to change where clang cindex can find the dynamic library.
Must be an absolute path to the file.
Example: `LIBCLANG_LIBRARY_FILE=/opt/llvm/lib/libclang.so.15.0.7`
- `LIBCLANG_LIBRARY_PATH`
Will be used to change where clang cindex can look for the dynamic library.
Must be an absolute path to the directory.
Example: `LIBCLANG_LIBRARY_PATH=/opt/llvm/lib`
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import logging
import os
import re
import sys
from os.path import realpath
from lib import (
definition_markers,
generate,
implementation_markers,
init_clang_cindex,
init_logging,
replace_in_file,
)
log = logging.getLogger("generate-and-replace-dir")
def main():
init_logging()
init_clang_cindex()
if len(sys.argv) < 2:
log.error(f"usage: {sys.argv[0]} <path-to-dir>")
sys.exit(1)
additional_includes = []
if len(sys.argv) >= 4 and sys.argv[2] == "--includes":
additional_includes = sys.argv[3].split(";")
log.debug(f"additional includes: {additional_includes}")
dir = realpath(sys.argv[1])
log.debug(f"Searching for header files in {dir}")
for path in os.scandir(dir):
if not path.is_file():
continue
if not path.name.endswith(".hpp"):
continue
header_path = path.path
source_path = re.sub(r"\.hpp$", ".cpp", header_path)
source_path = re.sub(r"include[/\\]twitch-eventsub-ws[/\\]", "src/", source_path)
if not os.path.isfile(source_path):
log.warning(f"Header file {header_path} did not have a matching source file at {source_path}")
continue
log.debug(f"Found header & source {header_path} / {source_path}")
(definition, implementation) = generate(header_path, additional_includes)
replace_in_file(header_path, definition_markers, definition)
replace_in_file(source_path, implementation_markers, implementation)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import logging
import sys
from lib import generate, init_clang_cindex, init_logging, temporary_file
log = logging.getLogger("generate")
def main():
init_logging()
init_clang_cindex()
if len(sys.argv) < 2:
log.error(f"Missing header file argument. Usage: {sys.argv[0]} <path-to-header-file>")
sys.exit(1)
(definitions, implementations) = generate(sys.argv[1])
with temporary_file() as (f, path):
log.debug(f"Write definitions to {path}")
f.write(definitions)
f.write("\n")
print(path)
with temporary_file() as (f, path):
log.debug(f"Write implementations to {path}")
f.write(implementations)
f.write("\n")
print(path)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import logging
from lib import get_clang_builtin_include_dirs, init_logging
log = logging.getLogger(__name__)
def main():
init_logging()
quote_includes, angle_includes = get_clang_builtin_include_dirs()
print("Quote includes:")
print("\n".join(quote_includes))
print()
print("Angle includes:")
print("\n".join(angle_includes))
print()
if __name__ == "__main__":
main()
@@ -0,0 +1,15 @@
from .generate import generate
from .helpers import get_clang_builtin_include_dirs, init_clang_cindex, temporary_file
from .logging import init_logging
from .replace import definition_markers, implementation_markers, replace_in_file
__all__ = [
"generate",
"get_clang_builtin_include_dirs",
"init_clang_cindex",
"temporary_file",
"init_logging",
"definition_markers",
"implementation_markers",
"replace_in_file",
]
+67
View File
@@ -0,0 +1,67 @@
from typing import List
import logging
import os
import clang.cindex
from .helpers import get_clang_builtin_include_dirs
from .struct import Struct
from .enum import Enum
from .walker import Walker
log = logging.getLogger(__name__)
def build_structs(filename: str, additional_includes: list[str] = []) -> List[Struct | Enum]:
if not os.path.isfile(filename):
raise ValueError(f"Path {filename} is not a file. cwd: {os.getcwd()}")
parse_args = [
"-std=c++17",
# Uncomment this if you need to debug where it tries to find headers
# "-H",
]
parse_options = (
clang.cindex.TranslationUnit.PARSE_INCOMPLETE | clang.cindex.TranslationUnit.PARSE_SKIP_FUNCTION_BODIES
)
extra_includes, system_includes = get_clang_builtin_include_dirs()
for include_dir in system_includes:
parse_args.append(f"-isystem{include_dir}")
for include_dir in extra_includes:
parse_args.append(f"-I{include_dir}")
for dir in additional_includes:
parse_args.append("-I")
parse_args.append(dir)
# Append default dirs
# - Append dir of file
file_dir = os.path.dirname(os.path.realpath(filename))
parse_args.append(f"-I{file_dir}")
# - Append project include dir
file_subdir = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(filename)), "../.."))
parse_args.append(f"-I{file_subdir}")
# TODO: Use build_commands if available
index = clang.cindex.Index.create()
log.debug("Parsing translation unit")
tu = index.parse(filename, args=parse_args, options=parse_options)
root = tu.cursor
for diag in tu.diagnostics:
log.warning(diag.location)
log.warning(diag.spelling)
log.warning(diag.option)
log.debug("Walking over AST")
walker = Walker(filename)
walker.walk(root)
return walker.enums + walker.structs
@@ -0,0 +1,37 @@
from typing import List, Tuple
import logging
import re
CommentCommands = List[Tuple[str, str]]
log = logging.getLogger(__name__)
def parse_comment_commands(raw_comment: str) -> CommentCommands:
comment_commands: CommentCommands = []
def clean_comment_line(line: str) -> str:
return line.replace("/", "").replace("*", "").strip()
comment_lines = [line for line in map(clean_comment_line, raw_comment.splitlines()) if line != ""]
for comment in comment_lines:
parts = comment.split("=", 2)
if len(parts) != 2:
continue
command = parts[0].strip()
value = parts[1].strip()
comment_commands.append((command, value))
return comment_commands
def json_transform(input_str: str, transformation: str) -> str:
match transformation:
case "snake_case":
return re.sub(r"(?<![A-Z])\B[A-Z]", r"_\g<0>", input_str).lower()
case other:
log.warning(f"Unknown transformation '{other}', ignoring")
return input_str
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import List
import logging
from jinja2 import Environment
from .comment_commands import CommentCommands
from .enum_constant import EnumConstant
log = logging.getLogger(__name__)
class Enum:
def __init__(self, name: str) -> None:
self.name = name
self.constants: List[EnumConstant] = []
self.parent: str = ""
self.comment_commands: CommentCommands = []
self.inner_root: str = ""
@property
def full_name(self) -> str:
if self.parent:
return f"{self.parent}::{self.name}"
else:
return self.name
def __eq__(self, other: object) -> bool:
if isinstance(other, self.__class__):
if self.name != other.name:
return False
return self.constants == other.constants
return False
def __str__(self) -> str:
pretty_constants = "\n ".join(map(str, self.constants))
return f"enum class {self.name} {{\n {pretty_constants}\n}}"
def try_value_to_implementation(self, env: Environment) -> str:
return env.get_template("enum-implementation.tmpl").render(enum=self)
def try_value_to_definition(self, env: Environment) -> str:
return env.get_template("enum-definition.tmpl").render(enum=self)
def apply_comment_commands(self, comment_commands: CommentCommands) -> None:
for command, value in comment_commands:
match command:
case "json_rename":
# Do nothing on enums
pass
case "json_dont_fail_on_deserialization":
# Do nothing on enums
pass
case "json_transform":
# Do nothing on enums
pass
case "json_inner":
self.inner_root = value
case other:
log.warning(f"Unknown comment command found: {other} with value {value}")
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Optional
import logging
import clang.cindex
from .comment_commands import CommentCommands, json_transform, parse_comment_commands
log = logging.getLogger(__name__)
class EnumConstant:
def __init__(
self,
name: str,
) -> None:
self.name = name
self.json_name = name
self.tag: Optional[str] = None
self.dont_fail_on_deserialization: bool = False
def apply_comment_commands(self, comment_commands: CommentCommands) -> None:
for command, value in comment_commands:
match command:
case "json_rename":
# Rename the key that this field will use in json terms
log.debug(f"Rename json key from {self.json_name} to {value}")
self.json_name = value
case "json_dont_fail_on_deserialization":
# Don't fail when an optional object exists and its data is bad
log.debug(f"Don't fail on deserialization for {self.name}")
self.dont_fail_on_deserialization = bool(value.lower() == "true")
case "json_transform":
# Transform the key from whatever-case to case specified by `value`
self.json_name = json_transform(self.json_name, value)
case "json_inner":
# Do nothing on members
pass
case "json_tag":
# Rename the key that this field will use in json terms
log.debug(f"Applied json tag on {self.json_name}: {value}")
self.tag = value
case other:
log.warning(f"Unknown comment command found: {other} with value {value}")
@staticmethod
def from_node(node: clang.cindex.Cursor) -> EnumConstant:
assert node.type is not None
name = node.spelling
enum = EnumConstant(name)
if node.raw_comment is not None:
comment_commands = parse_comment_commands(node.raw_comment)
enum.apply_comment_commands(comment_commands)
return enum
def __eq__(self, other: object) -> bool:
if not isinstance(other, self.__class__):
return False
if self.name != other.name:
return False
return True
def __repr__(self) -> str:
return f"{self.name}"
+13
View File
@@ -0,0 +1,13 @@
import logging
import subprocess
log = logging.getLogger(__name__)
def format_code(code: str) -> str:
proc = subprocess.Popen(["clang-format", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
outs, errs = proc.communicate(input=code.encode(), timeout=2)
if errs is not None:
log.warning(f"Error formatting code: {errs.decode()}")
return outs.decode()
@@ -0,0 +1,22 @@
from typing import Tuple
import logging
from .build import build_structs
from .format import format_code
from .helpers import init_clang_cindex, temporary_file
from .jinja_env import env
from .logging import init_logging
log = logging.getLogger(__name__)
def generate(header_path: str, additional_includes: list[str] = []) -> Tuple[str, str]:
structs = build_structs(header_path, additional_includes)
log.debug("Generate & format definitions")
definitions = format_code("\n\n".join([struct.try_value_to_definition(env) for struct in structs]))
log.debug("Generate & format implementations")
implementations = format_code("\n\n".join([struct.try_value_to_implementation(env) for struct in structs]))
return (definitions, implementations)
+119
View File
@@ -0,0 +1,119 @@
from typing import Generator, List, Tuple
import contextlib
import logging
import os
import subprocess
from io import TextIOWrapper
from tempfile import mkstemp
import clang.cindex
log = logging.getLogger(__name__)
def init_clang_cindex() -> None:
# 1. Has the LIBCLANG_LIBRARY_FILE environment variable been set? Use it
env_library_file = os.environ.get("LIBCLANG_LIBRARY_FILE")
if env_library_file is not None:
log.debug(f"Setting clang library file from LIBCLANG_LIBRARY_PATH environment variable: {env_library_file}")
clang.cindex.Config.set_library_file(env_library_file)
return
# 2. Has the LIBCLANG_LIBRARY_PATH environment variable been set? Use it
env_library_path = os.environ.get("LIBCLANG_LIBRARY_PATH")
if env_library_path is not None:
log.debug(
f"Setting clang library search path from LIBCLANG_LIBRARY_PATH environment variable: {env_library_path}"
)
clang.cindex.Config.set_library_path(env_library_path)
return
import ctypes.util
autodetected_path = ctypes.util.find_library("libclang" if os.name == "nt" else "clang")
if autodetected_path is not None:
log.debug(f"Autodetected clang library file: {autodetected_path}")
clang.cindex.Config.set_library_file(autodetected_path)
return
@contextlib.contextmanager
def temporary_file() -> Generator[Tuple[TextIOWrapper, str], None, None]:
fd, path = mkstemp()
log.debug(f"Opening file {path}")
f = os.fdopen(fd, "w")
yield (f, path)
f.flush()
f.close()
log.debug(f"Closed file {path}")
def get_clang_builtin_include_dirs() -> Tuple[List[str], List[str]]:
quote_includes: List[str] = []
angle_includes: List[str] = []
if os.name == "nt":
return [], []
cmd = [
"clang++",
"-E",
"-x",
"c++",
"-v",
"-",
]
path_entries = os.environ.get("PATH", "").split(os.pathsep)
llvm_path = os.environ.get("LLVM_PATH")
if llvm_path is not None:
path_entries.insert(0, os.path.join(llvm_path, "bin"))
path_str = os.pathsep.join(path_entries)
full_env = os.environ
full_env["PATH"] = path_str
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=full_env,
)
_, errs = proc.communicate(input=None, timeout=10)
doing_quote_includes = False
doing_angle_includes = False
for line in errs.decode("utf8").splitlines():
line = line.strip()
if line == r'#include "..." search starts here:':
doing_angle_includes = False
doing_quote_includes = True
continue
if line == r"#include <...> search starts here:":
doing_quote_includes = False
doing_angle_includes = True
continue
if line == r"End of search list.":
doing_quote_includes = False
doing_angle_includes = False
continue
if doing_quote_includes or doing_angle_includes:
if " " in line:
continue
line = line.split(" ", 1)[0]
p = os.path.realpath(line)
if doing_quote_includes:
quote_includes.append(p)
if doing_angle_includes:
angle_includes.append(p)
return (quote_includes, angle_includes)
@@ -0,0 +1,16 @@
import os
from jinja2 import Environment, FileSystemLoader
from jinja2_workarounds import MultiLineInclude
from .membertype import MemberType
template_paths = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
# print(f"Loading jinja templates from {template_paths}")
env = Environment(
loader=FileSystemLoader(template_paths),
extensions=[MultiLineInclude],
)
env.globals["MemberType"] = MemberType
+32
View File
@@ -0,0 +1,32 @@
import logging
import sys
from colorama import Fore, Style
def init_logging() -> None:
root = logging.getLogger()
root.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
colors = {
"WARNING": Fore.YELLOW,
"INFO": Fore.WHITE,
"DEBUG": Fore.BLUE,
"CRITICAL": Fore.YELLOW,
"ERROR": Fore.RED,
}
class ColoredFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
levelname = record.levelname
if levelname in colors:
levelname_color = Style.BRIGHT + colors[levelname] + levelname + Style.RESET_ALL
record.levelname = levelname_color
return logging.Formatter.format(self, record)
colored_formatter = ColoredFormatter("%(asctime)s %(levelname)-16s %(name)s: %(message)s")
handler.setFormatter(colored_formatter)
root.addHandler(handler)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Optional, List
import logging
import clang.cindex
from clang.cindex import CursorKind
from .comment_commands import CommentCommands, json_transform, parse_comment_commands
from .membertype import MemberType
log = logging.getLogger(__name__)
def get_type_name(type: clang.cindex.Type, namespace: List[str]) -> str:
if namespace:
namespace_str = f"{'::'.join(namespace)}::"
else:
namespace_str = ""
type_name = type.spelling
if type.is_const_qualified():
type_name = type_name.replace("const", "").strip()
type_name = type_name.removeprefix(namespace_str)
return type_name
class Member:
def __init__(
self,
name: str,
member_type: MemberType = MemberType.BASIC,
type_name: str = "?",
) -> None:
self.name = name
self.json_name = name
self.member_type = member_type
self.type_name = type_name
self.tag: Optional[str] = None
self.dont_fail_on_deserialization: bool = False
def apply_comment_commands(self, comment_commands: CommentCommands) -> None:
for command, value in comment_commands:
match command:
case "json_rename":
# Rename the key that this field will use in json terms
log.debug(f"Rename json key from {self.json_name} to {value}")
self.json_name = value
case "json_dont_fail_on_deserialization":
# Don't fail when an optional object exists and its data is bad
log.debug(f"Don't fail on deserialization for {self.name}")
self.dont_fail_on_deserialization = bool(value.lower() == "true")
case "json_transform":
# Transform the key from whatever-case to case specified by `value`
self.json_name = json_transform(self.json_name, value)
case "json_inner":
# Do nothing on members
pass
case "json_tag":
# Rename the key that this field will use in json terms
log.debug(f"Applied json tag on {self.json_name}: {value}")
self.tag = value
case other:
log.warning(f"Unknown comment command found: {other} with value {value}")
@staticmethod
def from_field(node: clang.cindex.Cursor, namespace: List[str]) -> Member:
assert node.type is not None
name = node.spelling
member_type = MemberType.BASIC
type_name = get_type_name(node.type, namespace)
log.debug(f"{node.spelling} - {type_name} - {node.type.is_const_qualified()}")
ntargs = node.type.get_num_template_arguments()
if ntargs > 0:
overwrite_member_type: Optional[MemberType] = None
# log.debug(node.type.get_template_argument_type(0).kind)
# log.debug(node.type.get_template_argument_type(0).spelling)
# log.debug(node.type.get_template_argument_type(0).get_named_type().spelling)
# log.debug(node.type.get_template_argument_type(0).get_class_type().spelling)
type_name = get_type_name(node.type.get_template_argument_type(0), namespace)
for xd in node.get_children():
match xd.kind:
case CursorKind.NAMESPACE_REF:
# Ignore namespaces
pass
case CursorKind.TEMPLATE_REF:
match xd.spelling:
case "optional":
match overwrite_member_type:
case None:
overwrite_member_type = MemberType.OPTIONAL
case other:
log.warning(f"Optional cannot be added on top of other member type: {other}")
case "vector":
match overwrite_member_type:
case None:
overwrite_member_type = MemberType.VECTOR
case MemberType.OPTIONAL:
overwrite_member_type = MemberType.OPTIONAL_VECTOR
case other:
log.warning(f"Vector cannot be added on top of other member type: {other}")
case other:
log.warning(f"Unhandled template type: {other}")
case CursorKind.TYPE_REF:
type_name = get_type_name(xd.type, namespace)
case other:
log.debug(f"Unhandled child kind type: {other}")
if overwrite_member_type is not None:
member_type = overwrite_member_type
member = Member(name, member_type, type_name)
if node.raw_comment is not None:
comment_commands = parse_comment_commands(node.raw_comment)
member.apply_comment_commands(comment_commands)
return member
def __eq__(self, other: object) -> bool:
if not isinstance(other, self.__class__):
return False
if self.name != other.name:
return False
if self.member_type != other.member_type:
return False
if self.type_name != other.type_name:
return False
return True
def __repr__(self) -> str:
match self.member_type:
case MemberType.BASIC:
return f"{self.type_name} {self.name}"
case MemberType.VECTOR:
return f"std::vector<{self.type_name}> {self.name}"
case MemberType.OPTIONAL:
return f"std::optional<{self.type_name}> {self.name}"
case MemberType.OPTIONAL_VECTOR:
return f"std::optional<std::vector<{self.type_name}>> {self.name}"
@@ -0,0 +1,8 @@
from enum import Enum
class MemberType(Enum):
BASIC = 1
VECTOR = 2
OPTIONAL = 3
OPTIONAL_VECTOR = 4
+41
View File
@@ -0,0 +1,41 @@
from typing import Tuple
import logging
log = logging.getLogger(__name__)
definition_markers: Tuple[str, str] = (
"// DESERIALIZATION DEFINITION START",
"// DESERIALIZATION DEFINITION END",
)
implementation_markers: Tuple[str, str] = (
"// DESERIALIZATION IMPLEMENTATION START",
"// DESERIALIZATION IMPLEMENTATION END",
)
def replace_in_file(
path: str,
markers: Tuple[str, str],
replacement: str,
) -> None:
with open(path, "r+") as fh:
# Read file into a list of strings
lines = [line.rstrip() for line in fh.readlines()]
start = lines.index(markers[0])
end = lines.index(markers[1], start)
log.debug(f"Markers: {start}-{end}")
lines[start + 1 : end] = [line.rstrip() for line in replacement.splitlines()]
# Clear file
fh.truncate(0)
fh.seek(0)
# Build new file data
data = "\n".join(lines)
# Add a newline at the end of the file
data += "\n"
# Write data to file
fh.write(data)
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import List
import logging
from jinja2 import Environment
from .comment_commands import CommentCommands
from .member import Member
log = logging.getLogger(__name__)
class Struct:
def __init__(self, name: str) -> None:
self.name = name
self.members: List[Member] = []
self.parent: str = ""
self.comment_commands: CommentCommands = []
self.inner_root: str = ""
@property
def full_name(self) -> str:
if self.parent:
return f"{self.parent}::{self.name}"
else:
return self.name
def __eq__(self, other: object) -> bool:
if isinstance(other, self.__class__):
if self.name != other.name:
return False
return self.members == other.members
return False
def __str__(self) -> str:
pretty_members = "\n ".join(map(str, self.members))
return f"struct {self.name} {{\n {pretty_members}\n}}"
def try_value_to_implementation(self, env: Environment) -> str:
return env.get_template("struct-implementation.tmpl").render(struct=self)
def try_value_to_definition(self, env: Environment) -> str:
return env.get_template("struct-definition.tmpl").render(struct=self)
def apply_comment_commands(self, comment_commands: CommentCommands) -> None:
for command, value in comment_commands:
match command:
case "json_rename":
# Do nothing on structs
pass
case "json_dont_fail_on_deserialization":
# Do nothing on structs
pass
case "json_transform":
# Do nothing on structs
pass
case "json_inner":
self.inner_root = value
case other:
log.warning(f"Unknown comment command found: {other} with value {value}")
@@ -0,0 +1,2 @@
boost::json::result_for<{{enum.full_name}}, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<{{enum.full_name}}>, const boost::json::value &jvRoot);
@@ -0,0 +1,21 @@
boost::json::result_for<{{enum.full_name}}, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<{{enum.full_name}}>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_string())
{
static const error::ApplicationErrorCategory errorMustBeString{"{{enum.full_name}} must be a string"};
return boost::system::error_code{129, errorMustBeString};
}
std::string_view eString(jvRoot.get_string());
using namespace std::string_view_literals;
{%- for constant in enum.constants %}
if (eString == "{{constant.json_name}}"sv) {
return {{enum.full_name}}::{{constant.name}};
}
{%- endfor %}
static const error::ApplicationErrorCategory errorEnumNameDidNotExist{"{{enum.full_name}} did not have a constant that matched this string"};
return boost::system::error_code{129, errorEnumNameDidNotExist};
}
@@ -0,0 +1 @@
return {{field.name}}.error();
@@ -0,0 +1,2 @@
static const error::ApplicationErrorCategory error_missing_field_{{field.name}}{"Missing required key {{field.json_name}}"};
return boost::system::error_code{129, error_missing_field_{{field.name}}};
@@ -0,0 +1,15 @@
const auto *jv{{field.name}} = root.if_contains("{{field.json_name}}");
if (jv{{field.name}} == nullptr)
{
{% include 'error-missing-field.tmpl' indent content %}
}
{% if field.tag %}
auto {{field.name}} = boost::json::try_value_to<{{field.type_name}}>(*jv{{field.name}}, {{field.tag}}());
{% else %}
auto {{field.name}} = boost::json::try_value_to<{{field.type_name}}>(*jv{{field.name}});
{% endif %}
if ({{field.name}}.has_error())
{
{% include 'error-failed-to-deserialize.tmpl' indent content %}
}
@@ -0,0 +1 @@
assert(false && "OPTIONAL VECTOR FIELD UNIMPLEMENTED");
@@ -0,0 +1,27 @@
std::optional<{{field.type_name}}> {{field.name}} = std::nullopt;
const auto *jv{{field.name}} = root.if_contains("{{field.json_name}}");
if (jv{{field.name}} != nullptr && !jv{{field.name}}->is_null())
{
{% if field.tag %}
auto t{{field.name}} = boost::json::try_value_to<{{field.type_name}}>(*jv{{field.name}}, {{field.tag}}());
{% else %}
auto t{{field.name}} = boost::json::try_value_to<{{field.type_name}}>(*jv{{field.name}});
{% endif %}
{% if field.dont_fail_on_deserialization %}
if (t{{field.name}}.has_error())
{
// TODO: report error in some way?
}
else
{
{{field.name}} = t{{field.name}}.value();
}
{% else %}
if (t{{field.name}}.has_error())
{
return t{{field.name}}.error();
}
{{field.name}} = std::move(t{{field.name}}.value());
{% endif %}
}
@@ -0,0 +1,14 @@
{% if field.tag -%}
static_assert(false && "JSON tag support is not implemented for vectors");
{%- endif %}
const auto *jv{{field.name}} = root.if_contains("{{field.json_name}}");
if (jv{{field.name}} == nullptr)
{
{% include 'error-missing-field.tmpl' indent content %}
}
const auto {{field.name}} = boost::json::try_value_to<std::vector<{{field.type_name}}>>(*jv{{field.name}});
if ({{field.name}}.has_error())
{
{% include 'error-failed-to-deserialize.tmpl' indent content %}
}
@@ -0,0 +1 @@
.{{field.name}} = std::move({{field.name}}.value()),
@@ -0,0 +1 @@
assert(false && "OPTIONAL VECTOR INITIALIZER UNIMPLEMENTED");
@@ -0,0 +1 @@
.{{field.name}} = std::move({{field.name}}),
@@ -0,0 +1 @@
.{{field.name}} = {{field.name}}.value(),
@@ -0,0 +1,2 @@
boost::json::result_for<{{struct.full_name}}, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<{{struct.full_name}}>, const boost::json::value &jvRoot);
@@ -0,0 +1,58 @@
boost::json::result_for<{{struct.full_name}}, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<{{struct.full_name}}>, const boost::json::value &jvRoot)
{
{% if struct.inner_root %}
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{"{{struct.full_name}} must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &outerRoot = jvRoot.get_object();
const auto *jvInnerRoot = outerRoot.if_contains("{{struct.inner_root}}");
if (jvInnerRoot == nullptr)
{
static const error::ApplicationErrorCategory errorMissing{"{{struct.full_name}}'s key {{struct.inner_root}} is missing"};
return boost::system::error_code{129, errorMissing};
}
if (!jvInnerRoot->is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{"{{struct.full_name}}'s {{struct.inner_root}} must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvInnerRoot->get_object();
{% else %}
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{"{{struct.full_name}} must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
{% endif %}
{% for field in struct.members %}
{% if field.member_type == MemberType.BASIC -%}
{% include 'field-basic.tmpl' indent content %}
{%- elif field.member_type == MemberType.VECTOR -%}
{% include 'field-vector.tmpl' indent content %}
{%- elif field.member_type == MemberType.OPTIONAL -%}
{% include 'field-optional.tmpl' indent content %}
{%- elif field.member_type == MemberType.OPTIONAL_VECTOR -%}
{% include 'field-optional-vector.tmpl' indent content %}
{%- endif -%}
{% endfor %}
return {{struct.full_name}}{
{%- for field in struct.members %}
{% if field.member_type == MemberType.BASIC -%}
{% include 'initializer-basic.tmpl' indent content %}
{%- elif field.member_type == MemberType.VECTOR -%}
{% include 'initializer-vector.tmpl' indent content %}
{%- elif field.member_type == MemberType.OPTIONAL -%}
{% include 'initializer-optional.tmpl' indent content %}
{%- elif field.member_type == MemberType.OPTIONAL_VECTOR -%}
{% include 'initializer-optional-vector.tmpl' indent content %}
{%- endif -%}
{% endfor %}
};
}
@@ -0,0 +1,14 @@
#include <optional>
#include <vector>
struct Pod {
};
struct Const {
const int a;
const bool b;
const char c;
const Pod d;
const std::vector<bool> e;
const std::optional<bool> f;
};
@@ -0,0 +1,5 @@
#include <optional>
struct Optional {
std::optional<bool> a;
};
@@ -0,0 +1,5 @@
struct Simple {
int a;
bool b;
char c;
};
@@ -0,0 +1,10 @@
#include <optional>
#include <string>
#include <vector>
struct String {
std::string a;
const std::string b;
std::vector<std::string> c;
std::optional<std::string> d;
};
@@ -0,0 +1,27 @@
#include <optional>
#include <vector>
struct Foo {
struct InnerFoo {
int asd;
};
int test;
};
struct S {
Foo a;
std::vector<Foo> as;
/**
* 4
* 5
* json_rename=vec_of_ints
**/
std::vector<int> vecOfInts;
std::optional<Foo> ab;
/**
* json_dont_fail_on_deserialization=True
**/
std::optional<Foo> ac;
int b;
};
@@ -0,0 +1,11 @@
#include <vector>
struct Pod {
int a;
bool b;
char c;
};
struct VectorPod {
std::vector<Pod> a;
};
@@ -0,0 +1,6 @@
#include <vector>
struct Vector {
std::vector<bool> a;
std::vector<std::vector<bool>> b;
};
@@ -0,0 +1,175 @@
from lib.build import build_structs
from lib.helpers import init_clang_cindex
from lib.membertype import MemberType
import pytest
def init_clang():
init_clang_cindex()
def test_simple():
structs = build_structs("lib/tests/resources/simple.hpp")
assert len(structs) == 1
s = structs[0]
assert s.name == "Simple"
assert len(s.members) == 3
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.BASIC
assert s.members[0].type_name == "int"
assert s.members[1].name == "b"
assert s.members[1].member_type == MemberType.BASIC
assert s.members[1].type_name == "bool"
assert s.members[2].name == "c"
assert s.members[2].member_type == MemberType.BASIC
assert s.members[2].type_name == "char"
def test_vector():
import clang.cindex
print(clang.cindex.conf.get_filename())
structs = build_structs("lib/tests/resources/vector.hpp")
assert len(structs) == 1
s = structs[0]
assert s.name == "Vector"
assert len(s.members) == 2
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.VECTOR
assert s.members[0].type_name == "bool"
assert s.members[1].name == "b"
assert s.members[1].member_type == MemberType.VECTOR
assert s.members[1].type_name == "std::vector<bool>"
def test_optional():
import clang.cindex
print(clang.cindex.conf.get_filename())
structs = build_structs("lib/tests/resources/optional.hpp")
assert len(structs) == 1
s = structs[0]
assert s.name == "Optional"
assert len(s.members) == 1
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.OPTIONAL
assert s.members[0].type_name == "bool"
def test_vector_pod():
import clang.cindex
print(clang.cindex.conf.get_filename())
structs = build_structs("lib/tests/resources/vector-pod.hpp")
assert len(structs) == 2
s = structs[0]
assert s.name == "Pod"
assert len(s.members) == 3
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.BASIC
assert s.members[0].type_name == "int"
assert s.members[1].name == "b"
assert s.members[1].member_type == MemberType.BASIC
assert s.members[1].type_name == "bool"
assert s.members[2].name == "c"
assert s.members[2].member_type == MemberType.BASIC
assert s.members[2].type_name == "char"
s = structs[1]
assert s.name == "VectorPod"
assert len(s.members) == 1
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.VECTOR
assert s.members[0].type_name == "Pod"
def test_const():
import clang.cindex
print(clang.cindex.conf.get_filename())
structs = build_structs("lib/tests/resources/const.hpp")
assert len(structs) == 2
s = structs[0]
assert s.name == "Pod"
assert len(s.members) == 0
s = structs[1]
assert s.name == "Const"
assert len(s.members) == 6
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.BASIC
assert s.members[0].type_name == "int"
assert s.members[1].name == "b"
assert s.members[1].member_type == MemberType.BASIC
assert s.members[1].type_name == "bool"
assert s.members[2].name == "c"
assert s.members[2].member_type == MemberType.BASIC
assert s.members[2].type_name == "char"
assert s.members[3].name == "d"
assert s.members[3].member_type == MemberType.BASIC
assert s.members[3].type_name == "Pod"
assert s.members[4].name == "e"
assert s.members[4].member_type == MemberType.VECTOR
assert s.members[4].type_name == "bool"
assert s.members[5].name == "f"
assert s.members[5].member_type == MemberType.OPTIONAL
assert s.members[5].type_name == "bool"
def test_string():
import clang.cindex
print(clang.cindex.conf.get_filename())
structs = build_structs("lib/tests/resources/string.hpp")
assert len(structs) == 1
s = structs[0]
assert s.name == "String"
assert len(s.members) == 4
assert s.members[0].name == "a"
assert s.members[0].member_type == MemberType.BASIC
assert s.members[0].type_name == "std::string"
assert s.members[1].name == "b"
assert s.members[1].member_type == MemberType.BASIC
assert s.members[1].type_name == "std::string"
assert s.members[2].name == "c"
assert s.members[2].member_type == MemberType.VECTOR
assert s.members[2].type_name == "std::string"
assert s.members[3].name == "d"
assert s.members[3].member_type == MemberType.OPTIONAL
assert s.members[3].type_name == "std::string"
init_clang()
+102
View File
@@ -0,0 +1,102 @@
from typing import List, Optional
import logging
import os
import clang.cindex
from clang.cindex import CursorKind
from .comment_commands import parse_comment_commands
from .member import Member
from .enum_constant import EnumConstant
from .struct import Struct
from .enum import Enum
log = logging.getLogger(__name__)
class Walker:
def __init__(self, filename: str) -> None:
self.filename = filename
self.real_filepath = os.path.realpath(self.filename)
self.structs: List[Struct] = []
self.enums: List[Enum] = []
self.namespace: List[str] = []
def handle_node(self, node: clang.cindex.Cursor, struct: Optional[Struct], enum: Optional[Enum]) -> bool:
match node.kind:
case CursorKind.STRUCT_DECL:
new_struct = Struct(node.spelling)
if node.raw_comment is not None:
new_struct.comment_commands = parse_comment_commands(node.raw_comment)
new_struct.apply_comment_commands(new_struct.comment_commands)
if struct is not None:
new_struct.parent = struct.full_name
for child in node.get_children():
self.handle_node(child, new_struct, None)
self.structs.append(new_struct)
return True
case CursorKind.ENUM_DECL:
new_enum = Enum(node.spelling)
if node.raw_comment is not None:
new_enum.comment_commands = parse_comment_commands(node.raw_comment)
new_enum.apply_comment_commands(new_enum.comment_commands)
if struct is not None:
new_enum.parent = struct.full_name
for child in node.get_children():
self.handle_node(child, None, new_enum)
self.enums.append(new_enum)
return True
case CursorKind.ENUM_CONSTANT_DECL:
if enum:
# log.warning(
# f"enum constant decl {node.spelling} - enum comments: {enum.comment_commands} - node comments: {node.raw_comment}"
# )
constant = EnumConstant.from_node(node)
constant.apply_comment_commands(enum.comment_commands)
enum.constants.append(constant)
case CursorKind.FIELD_DECL:
type = node.type
if type is None:
# Skip nodes without a type
return False
# log.debug(f"{struct}: {type.spelling} {node.spelling} ({type.kind})")
if struct:
member = Member.from_field(node, self.namespace)
member.apply_comment_commands(struct.comment_commands)
struct.members.append(member)
case CursorKind.NAMESPACE:
self.namespace.append(node.spelling)
# Ignore namespaces
pass
case CursorKind.FUNCTION_DECL:
# Ignore function declarations
pass
case _:
# log.warning(f"unhandled kind: {node.kind}")
pass
return False
def walk(self, node: clang.cindex.Cursor) -> None:
if node.location.file and not clang.cindex.Config().lib.clang_Location_isFromMainFile(node.location):
return
handled = self.handle_node(node, None, None)
if not handled:
for child in node.get_children():
self.walk(child)
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import logging
import sys
from os.path import realpath
from lib import definition_markers, implementation_markers, init_logging, replace_in_file
log = logging.getLogger("replace-in-file")
def main():
init_logging()
if len(sys.argv) < 3:
log.error(f"usage: {sys.argv[0]} <path-to-header-file> <path-to-source-file>")
sys.exit(1)
# The files where we will do the replacements
header_path = realpath(sys.argv[1])
source_path = realpath(sys.argv[2])
# The files where we read the generated definitions & implementations from
# TODO: pass these along in argv too?
stdin_lines = sys.stdin.readlines()
generated_definition_path = realpath(stdin_lines[0].strip())
generated_implementation_path = realpath(stdin_lines[1].strip())
log.debug(f"Generated files: {generated_definition_path} / {generated_implementation_path}")
log.debug(f"Real files: {header_path} / {source_path}")
with open(generated_definition_path, "r") as generated_fh:
replacement = generated_fh.read()
replace_in_file(source_path, definition_markers, replacement)
with open(generated_implementation_path, "r") as generated_fh:
replacement = generated_fh.read()
replace_in_file(source_path, implementation_markers, replacement)
if __name__ == "__main__":
main()
@@ -0,0 +1,4 @@
black==23.3.0
flake8==6.0.0
mypy==1.3.0
isort==5.12.0
@@ -0,0 +1,6 @@
clang==15.0.7
Jinja2==3.1.2
jinja2-workarounds==0.1.0
clang-format==16.0.4
colorama==0.4.6
pytest==7.3.1
@@ -0,0 +1,54 @@
function(generate_json_impls)
set(_one_value_opts SRC_TARGET GEN_TARGET)
cmake_parse_arguments(PARSE_ARGV 0 arg
"" "${_one_value_opts}" ""
)
if (DEFINED arg_KEYWORDS_MISSING_VALUES)
message(FATAL_ERROR "generate_json_impls: Missing ${arg_KEYWORDS_MISSING_VALUES}")
endif()
set(_venv_path "${CMAKE_CURRENT_BINARY_DIR}/eventsub-venv")
if(WIN32)
set(_venv_python_path "${_venv_path}/Scripts/python.exe")
set(_venv_pip_path "${_venv_path}/Scripts/pip.exe")
else()
set(_venv_python_path "${_venv_path}/bin/python3")
set(_venv_pip_path "${_venv_path}/bin/pip3")
endif()
cmake_path(SET _requirements NORMALIZE "${CMAKE_CURRENT_LIST_DIR}/ast/requirements.txt")
cmake_path(SET _gen_script NORMALIZE "${CMAKE_CURRENT_LIST_DIR}/ast/generate-and-replace-dir.py")
cmake_path(SET _search_dir NORMALIZE "${CMAKE_CURRENT_LIST_DIR}/include/twitch-eventsub-ws")
get_target_property(_inc_dirs ${arg_SRC_TARGET} INCLUDE_DIRECTORIES)
list(APPEND _inc_dirs ${Boost_INCLUDE_DIRS})
list(APPEND _inc_dirs ${OPENSSL_INCLUDE_DIR})
list(APPEND _inc_dirs ${Qt${MAJOR_QT_VERSION}Core_INCLUDE_DIRS})
list(JOIN _inc_dirs ";" _inc_dir)
find_package(Python3 COMPONENTS Interpreter)
if(NOT Python3_FOUND)
return()
endif()
add_custom_target(${arg_GEN_TARGET}_python_venv_create
COMMENT "Creating python virtual environment"
COMMAND "${Python3_EXECUTABLE}" -m venv "${_venv_path}"
VERBATIM
)
add_custom_target(${arg_GEN_TARGET}_python_venv_install
COMMENT "Installing python dependencies"
COMMAND "${_venv_pip_path}" install -r "${_requirements}"
DEPENDS "${_requirements}" ${arg_GEN_TARGET}_python_venv_create
VERBATIM
)
add_custom_target(${arg_GEN_TARGET}
COMMENT "Generating JSON deserializers"
DEPENDS ${arg_GEN_TARGET}_python_venv_install
COMMAND "${_venv_python_path}" "${_gen_script}" "${_search_dir}/messages" --includes "${_inc_dir}"
COMMAND "${_venv_python_path}" "${_gen_script}" "${_search_dir}/payloads" --includes "${_inc_dir}"
VERBATIM
)
endfunction()
@@ -0,0 +1,51 @@
cmake_minimum_required(VERSION 3.15)
cmake_policy(SET CMP0087 NEW) # evaluates generator expressions in `install(CODE/SCRIPT)`
cmake_policy(SET CMP0091 NEW) # select MSVC runtime library through `CMAKE_MSVC_RUNTIME_LIBRARY`
project(twitch-eventsub-ws-example VERSION 0.1.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(${PROJECT_NAME} main.cpp)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/.." twitch-eventsub-ws)
find_library(LIBRT rt)
find_package(Threads REQUIRED)
target_link_libraries(${PROJECT_NAME}
PUBLIC
Threads::Threads
twitch-eventsub-ws
)
# Set the output of TARGET to be
# - CMAKE_BIN_DIR/lib for libraries
# - CMAKE_BIN_DIR/bin for BINARIES
# an additional argument specifies the subdirectory.
function(set_target_directory_hierarchy TARGET)
set_target_properties(${TARGET}
PROPERTIES
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib/${ARGV1}"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib/${ARGV1}"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/${ARGV1}"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin/${ARGV1}"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin/${ARGV1}"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin/${ARGV1}"
)
endfunction()
set_target_directory_hierarchy(${PROJECT_NAME})
if (MSVC)
target_compile_options(${PROJECT_NAME} PUBLIC /EHsc /bigobj)
endif ()
if (LIBRT)
target_link_libraries(${PROJECT_NAME}
PUBLIC
${LIBRT}
)
endif ()
+152
View File
@@ -0,0 +1,152 @@
#include "twitch-eventsub-ws/listener.hpp"
#include "twitch-eventsub-ws/session.hpp"
#include "twitch-eventsub-ws/string.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/json.hpp>
#include <qdebug.h>
#include <qlogging.h>
#include <iostream>
#include <memory>
using namespace chatterino::eventsub;
class MyListener final : public lib::Listener
{
public:
void onSessionWelcome(
lib::messages::Metadata metadata,
lib::payload::session_welcome::Payload payload) override
{
(void)metadata;
std::cout << "ON session welcome " << payload.id << " XD\n";
}
void onNotification(lib::messages::Metadata metadata,
const boost::json::value &jv) override
{
(void)metadata;
std::cout << "on notification: " << jv << '\n';
}
void onChannelBan(lib::messages::Metadata metadata,
lib::payload::channel_ban::v1::Payload payload) override
{
(void)metadata;
std::cout << "Channel ban occured in "
<< payload.event.broadcasterUserLogin << "'s channel:"
<< " isPermanent=" << payload.event.isPermanent
<< " reason=" << payload.event.reason
<< " userLogin=" << payload.event.userLogin
<< " moderatorLogin=" << payload.event.moderatorUserLogin
<< '\n';
}
void onStreamOnline(
lib::messages::Metadata metadata,
lib::payload::stream_online::v1::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "ON STREAM ONLINE XD\n";
}
void onStreamOffline(
lib::messages::Metadata metadata,
lib::payload::stream_offline::v1::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "ON STREAM OFFLINE XD\n";
}
void onChannelChatNotification(
lib::messages::Metadata metadata,
lib::payload::channel_chat_notification::v1::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "Received channel.chat.notification v1\n";
}
void onChannelUpdate(
lib::messages::Metadata metadata,
lib::payload::channel_update::v1::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "Channel update event!\n";
}
void onChannelChatMessage(
lib::messages::Metadata metadata,
lib::payload::channel_chat_message::v1::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "Channel chat message event!\n";
}
void onChannelModerate(
lib::messages::Metadata metadata,
lib::payload::channel_moderate::v2::Payload payload) override
{
(void)metadata;
(void)payload;
std::cout << "Channel moderate event!\n";
}
// Add your new subscription types above this line
};
int main(int argc, char **argv)
{
(void)argc;
(void)argv;
lib::String a("foo");
// qDebug() << "xd1:"
// << QString::fromStdString(std::get<std::string>(a.backingString));
qDebug() << "xd2:" << a.qt();
qDebug() << "xd3:" << a.qt();
std::string userAgent{"chatterino-eventsub-testing"};
// for use with twitch CLI: twitch event websocket start-server --ssl --port 3012
// std::string host{"localhost"};
// std::string port{"3012"};
// std::string path{"/ws"};
// for use with websocat: websocat -s 8080 --pkcs12-der certificate.p12
std::string host{"localhost"};
std::string port{"8080"};
std::string path;
// for use with real Twitch eventsub
// std::string host{"eventsub.wss.twitch.tv"};
// std::string port("443");
// std::string path("/ws");
try
{
boost::asio::io_context ctx(1);
boost::asio::ssl::context sslContext{
boost::asio::ssl::context::tlsv12_client};
// TODO: Load certificates into SSL context
std::make_shared<lib::Session>(ctx, sslContext,
std::make_unique<MyListener>())
->run(host, port, path, userAgent);
ctx.run();
}
catch (std::exception &e)
{
std::cerr << "Exception: " << e.what() << "\n";
}
}
@@ -0,0 +1,19 @@
#pragma once
#include <boost/json.hpp>
#include <chrono>
#include <sstream>
namespace chatterino::eventsub::lib {
struct AsISO8601 {
};
boost::json::result_for<std::chrono::system_clock::time_point,
boost::json::value>::type
tag_invoke(
boost::json::try_value_to_tag<std::chrono::system_clock::time_point>,
const boost::json::value &jvRoot, const AsISO8601 &);
} // namespace chatterino::eventsub::lib
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
#pragma once
#include <boost/system/error_category.hpp>
#include <string>
namespace chatterino::eventsub::lib::error {
class ApplicationErrorCategory final : public boost::system::error_category
{
const std::string innerMessage;
public:
ApplicationErrorCategory(const char *_innerMessage)
: innerMessage(_innerMessage)
{
}
explicit ApplicationErrorCategory(std::string _innerMessage)
: innerMessage(std::move(_innerMessage))
{
}
const char *name() const noexcept override
{
return "Application JSON error";
}
std::string message(int /*ev*/) const override
{
return this->innerMessage;
}
};
const ApplicationErrorCategory EXPECTED_OBJECT{"Expected object"};
const ApplicationErrorCategory MISSING_KEY{"Missing key"};
} // namespace chatterino::eventsub::lib::error
@@ -0,0 +1,35 @@
#pragma once
#include <boost/json/object.hpp>
#include <iostream>
#include <optional>
#include <string_view>
namespace chatterino::eventsub::lib {
template <typename T>
std::optional<T> readMember(const boost::json::object &obj,
std::string_view key)
{
const auto *it = obj.find(key);
if (it == obj.end())
{
// No member with the key found
std::cerr << "No member with the key " << key << " found\n";
return std::nullopt;
}
const auto result = boost::json::try_value_to<T>(it->value());
if (!result.has_value())
{
std::cerr << key << " could not be deserialized to the desired type\n";
// Member could not be serialized to this type
return std::nullopt;
}
return result.value();
}
} // namespace chatterino::eventsub::lib
@@ -0,0 +1,58 @@
#pragma once
#include "twitch-eventsub-ws/messages/metadata.hpp"
#include "twitch-eventsub-ws/payloads/channel-ban-v1.hpp"
#include "twitch-eventsub-ws/payloads/channel-chat-message-v1.hpp"
#include "twitch-eventsub-ws/payloads/channel-chat-notification-v1.hpp"
#include "twitch-eventsub-ws/payloads/channel-moderate-v2.hpp"
#include "twitch-eventsub-ws/payloads/channel-update-v1.hpp"
#include "twitch-eventsub-ws/payloads/session-welcome.hpp"
#include "twitch-eventsub-ws/payloads/stream-offline-v1.hpp"
#include "twitch-eventsub-ws/payloads/stream-online-v1.hpp"
namespace chatterino::eventsub::lib {
class Listener
{
public:
virtual ~Listener() = default;
virtual void onSessionWelcome(
messages::Metadata metadata,
payload::session_welcome::Payload payload) = 0;
virtual void onNotification(messages::Metadata metadata,
const boost::json::value &jv) = 0;
// Subscription types
virtual void onChannelBan(messages::Metadata metadata,
payload::channel_ban::v1::Payload payload) = 0;
virtual void onStreamOnline(
messages::Metadata metadata,
payload::stream_online::v1::Payload payload) = 0;
virtual void onStreamOffline(
messages::Metadata metadata,
payload::stream_offline::v1::Payload payload) = 0;
virtual void onChannelChatNotification(
messages::Metadata metadata,
payload::channel_chat_notification::v1::Payload payload) = 0;
virtual void onChannelUpdate(
messages::Metadata metadata,
payload::channel_update::v1::Payload payload) = 0;
virtual void onChannelChatMessage(
messages::Metadata metadata,
payload::channel_chat_message::v1::Payload payload) = 0;
virtual void onChannelModerate(
messages::Metadata metadata,
payload::channel_moderate::v2::Payload payload) = 0;
// Add your new subscription types above this line
};
} // namespace chatterino::eventsub::lib
@@ -0,0 +1,41 @@
#pragma once
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
#include <optional>
#include <string>
namespace chatterino::eventsub::lib::messages {
/*
{
"metadata": {
"message_id": "40cc68b8-dc5b-a46e-0388-a7c9193eec5e",
"message_type": "session_welcome",
"message_timestamp": "2023-05-14T12:31:47.995298776Z"
"subscription_type": "channel.unban", // only included on message_type=notification
"subscription_version": "1" // only included on message_type=notification
},
"payload": ...
}
*/
/// json_transform=snake_case
struct Metadata {
const std::string messageID;
const std::string messageType;
// TODO: should this be chronofied?
const std::string messageTimestamp;
const std::optional<std::string> subscriptionType;
const std::optional<std::string> subscriptionVersion;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Metadata, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Metadata>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::messages
@@ -0,0 +1,108 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <chrono>
#include <string>
namespace chatterino::eventsub::lib::payload::channel_ban::v1 {
/*
{
"metadata": ...,
"payload": {
"subscription": {
"id": "4aa632e0-fca3-590b-e981-bbd12abdb3fe",
"status": "enabled",
"type": "channel.ban",
"version": "1",
"condition": {
"broadcaster_user_id": "74378979"
},
"transport": {
"method": "websocket",
"session_id": "38de428e_b11f07be"
},
"created_at": "2023-05-20T12:30:55.518375571Z",
"cost": 0
},
"event": {
"banned_at": "2023-05-20T12:30:55.518375571Z",
"broadcaster_user_id": "74378979",
"broadcaster_user_login": "testBroadcaster",
"broadcaster_user_name": "testBroadcaster",
"ends_at": "2023-05-20T12:40:55.518375571Z",
"is_permanent": false,
"moderator_user_id": "29024944",
"moderator_user_login": "CLIModerator",
"moderator_user_name": "CLIModerator",
"reason": "This is a test event",
"user_id": "40389552",
"user_login": "testFromUser",
"user_name": "testFromUser"
}
}
}
*/
/// json_transform=snake_case
struct Event {
// User ID (e.g. 117166826) of the user who's channel the event took place in
std::string broadcasterUserID;
// User Login (e.g. testaccount_420) of the user who's channel the event took place in
std::string broadcasterUserLogin;
// User Name (e.g. 테스트계정420) of the user who's channel the event took place in
std::string broadcasterUserName;
// User ID (e.g. 117166826) of the user who took the action
std::string moderatorUserID;
// User Login (e.g. testaccount_420) of the user who took the action
std::string moderatorUserLogin;
// User Name (e.g. 테스트계정420) of the user who took the action
std::string moderatorUserName;
// User ID (e.g. 117166826) of the user who was timed out or banned
std::string userID;
// User Login (e.g. testaccount_420) of the user who was timed out or banned
std::string userLogin;
// User Name (e.g. 테스트계정420) of the user who was timed out or banned
std::string userName;
// Reason given for the timeout or ban.
// If no reason was specified, this string is empty
std::string reason;
// Set to true if this was a ban.
// If this is false, this event describes a timeout
bool isPermanent;
// Time point when the timeout or ban took place
/// json_tag=AsISO8601
std::chrono::system_clock::time_point bannedAt;
// Time point when the timeout will end
/// json_tag=AsISO8601
std::optional<std::chrono::system_clock::time_point> endsAt;
// Returns the duration of the timeout
// If this event describes a ban, the value returned won't make sense
std::chrono::system_clock::duration timeoutDuration() const;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_ban::v1
@@ -0,0 +1,185 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <optional>
#include <string>
#include <vector>
/*
{
...,
"event": {
"broadcaster_user_id": "117166826",
"broadcaster_user_login": "testaccount_420",
"broadcaster_user_name": "테스트계정420",
"chatter_user_id": "100135110",
"chatter_user_login": "streamelements",
"chatter_user_name": "StreamElements",
"message_id": "7732e85c-4543-464f-9d84-533e73f71459",
"message": {
"text": "https://youtu.be/v515yo0Ad_M",
"fragments": [
{
"type": "text",
"text": "https://youtu.be/v515yo0Ad_M",
"cheermote": null,
"emote": null,
"mention": null
}
]
},
"color": "#5B99FF",
"badges": [
{
"set_id": "moderator",
"id": "1",
"info": ""
},
{
"set_id": "partner",
"id": "1",
"info": ""
}
],
"message_type": "text",
"cheer": null,
"reply": null,
"channel_points_custom_reward_id": null
}
}
*/
namespace chatterino::eventsub::lib::payload::channel_chat_message::v1 {
/// json_transform=snake_case
struct Badge {
std::string setID;
std::string id;
std::string info;
};
/// json_transform=snake_case
struct Cheermote {
std::string prefix;
int bits;
int tier;
};
/// json_transform=snake_case
struct Emote {
std::string id;
std::string emoteSetID;
std::string ownerID;
std::vector<std::string> format;
};
/// json_transform=snake_case
struct Mention {
std::string userID;
std::string userName;
std::string userLogin;
};
/// json_transform=snake_case
struct MessageFragment {
std::string type;
std::string text;
std::optional<Cheermote> cheermote;
std::optional<Emote> emote;
std::optional<Mention> mention;
};
/// json_transform=snake_case
struct Message {
std::string text;
std::vector<MessageFragment> fragments;
};
/// json_transform=snake_case
struct Cheer {
int bits;
};
/// json_transform=snake_case
struct Reply {
std::string parentMessageID;
std::string parentUserID;
std::string parentUserLogin;
std::string parentUserName;
std::string parentMessageBody;
std::string threadMessageID;
std::string threadUserID;
std::string threadUserLogin;
std::string threadUserName;
};
/// json_transform=snake_case
struct Event {
// Broadcaster of the channel the message was sent in
std::string broadcasterUserID;
std::string broadcasterUserLogin;
std::string broadcasterUserName;
// User who sent the message
std::string chatterUserID;
std::string chatterUserLogin;
std::string chatterUserName;
// Color of the user who sent the message
std::string color;
std::vector<Badge> badges;
std::string messageID;
std::string messageType;
Message message;
std::optional<Cheer> cheer;
std::optional<Reply> reply;
std::optional<std::string> channelPointsCustomRewardID;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Badge, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Badge>, const boost::json::value &jvRoot);
boost::json::result_for<Cheermote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Cheermote>, const boost::json::value &jvRoot);
boost::json::result_for<Emote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Emote>, const boost::json::value &jvRoot);
boost::json::result_for<Mention, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Mention>, const boost::json::value &jvRoot);
boost::json::result_for<MessageFragment, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<MessageFragment>,
const boost::json::value &jvRoot);
boost::json::result_for<Message, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Message>, const boost::json::value &jvRoot);
boost::json::result_for<Cheer, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Cheer>, const boost::json::value &jvRoot);
boost::json::result_for<Reply, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Reply>, const boost::json::value &jvRoot);
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_chat_message::v1
@@ -0,0 +1,267 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <optional>
#include <string>
#include <vector>
namespace chatterino::eventsub::lib::payload::channel_chat_notification::v1 {
/// json_transform=snake_case
struct Badge {
std::string setID;
std::string id;
std::string info;
};
/// json_transform=snake_case
struct Cheermote {
std::string prefix;
int bits;
int tier;
};
/// json_transform=snake_case
struct Emote {
std::string id;
std::string emoteSetID;
std::string ownerID;
std::vector<std::string> format;
};
/// json_transform=snake_case
struct Mention {
std::string userID;
std::string userName;
std::string userLogin;
};
/// json_transform=snake_case
struct MessageFragment {
std::string type;
std::string text;
std::optional<Cheermote> cheermote;
std::optional<Emote> emote;
std::optional<Mention> mention;
};
/// json_transform=snake_case
struct Subcription {
std::string subTier;
bool isPrime;
int durationMonths;
};
/// json_transform=snake_case
struct Resubscription {
int cumulativeMonths;
int durationMonths;
std::optional<int> streakMonths;
std::string subTier;
bool isPrime;
bool isGift;
bool gifterIsAnonymous;
std::optional<std::string> gifterUserID;
std::optional<std::string> gifterUserName;
std::optional<std::string> gifterUserLogin;
};
/// json_transform=snake_case
struct GiftSubscription {
int durationMonths;
std::optional<int> cumulativeTotal;
std::optional<int> streakMonths;
std::string recipientUserID;
std::string recipientUserName;
std::string recipientUserLogin;
std::string subTier;
std::optional<std::string> communityGiftID;
};
/// json_transform=snake_case
struct CommunityGiftSubscription {
std::string id;
int total;
std::string subTier;
std::optional<int> cumulativeTotal;
};
/// json_transform=snake_case
struct GiftPaidUpgrade {
bool gifterIsAnonymous;
std::optional<std::string> gifterUserID;
std::optional<std::string> gifterUserName;
std::optional<std::string> gifterUserLogin;
};
/// json_transform=snake_case
struct PrimePaidUpgrade {
std::string subTier;
};
/// json_transform=snake_case
struct Raid {
std::string userID;
std::string userName;
std::string userLogin;
int viewerCount;
std::string profileImageURL;
};
/// json_transform=snake_case
struct Unraid {
};
/// json_transform=snake_case
struct PayItForward {
bool gifterIsAnonymous;
std::optional<std::string> gifterUserID;
std::optional<std::string> gifterUserName;
std::optional<std::string> gifterUserLogin;
};
/// json_transform=snake_case
struct Announcement {
std::string color;
};
/// json_transform=snake_case
struct CharityDonationAmount {
int value;
int decimalPlaces;
std::string currency;
};
/// json_transform=snake_case
struct CharityDonation {
std::string charityName;
CharityDonationAmount amount;
};
/// json_transform=snake_case
struct BitsBadgeTier {
int tier;
};
/// json_transform=snake_case
struct Message {
std::string text;
std::vector<MessageFragment> fragments;
};
/// json_transform=snake_case
struct Event {
std::string broadcasterUserID;
std::string broadcasterUserLogin;
std::string broadcasterUserName;
std::string chatterUserID;
std::string chatterUserLogin;
std::string chatterUserName;
bool chatterIsAnonymous;
std::string color;
std::vector<Badge> badges;
std::string systemMessage;
std::string messageID;
Message message;
std::string noticeType;
std::optional<Subcription> sub;
std::optional<Resubscription> resub;
std::optional<GiftSubscription> subGift;
std::optional<CommunityGiftSubscription> communitySubGift;
std::optional<GiftPaidUpgrade> giftPaidUpgrade;
std::optional<PrimePaidUpgrade> primePaidUpgrade;
std::optional<Raid> raid;
std::optional<Unraid> unraid;
std::optional<PayItForward> payItForward;
std::optional<Announcement> announcement;
std::optional<CharityDonation> charityDonation;
std::optional<BitsBadgeTier> bitsBadgeTier;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Badge, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Badge>, const boost::json::value &jvRoot);
boost::json::result_for<Cheermote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Cheermote>, const boost::json::value &jvRoot);
boost::json::result_for<Emote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Emote>, const boost::json::value &jvRoot);
boost::json::result_for<Mention, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Mention>, const boost::json::value &jvRoot);
boost::json::result_for<MessageFragment, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<MessageFragment>,
const boost::json::value &jvRoot);
boost::json::result_for<Subcription, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Subcription>,
const boost::json::value &jvRoot);
boost::json::result_for<Resubscription, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Resubscription>,
const boost::json::value &jvRoot);
boost::json::result_for<GiftSubscription, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<GiftSubscription>,
const boost::json::value &jvRoot);
boost::json::result_for<CommunityGiftSubscription, boost::json::value>::type
tag_invoke(boost::json::try_value_to_tag<CommunityGiftSubscription>,
const boost::json::value &jvRoot);
boost::json::result_for<GiftPaidUpgrade, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<GiftPaidUpgrade>,
const boost::json::value &jvRoot);
boost::json::result_for<PrimePaidUpgrade, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<PrimePaidUpgrade>,
const boost::json::value &jvRoot);
boost::json::result_for<Raid, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Raid>, const boost::json::value &jvRoot);
boost::json::result_for<Unraid, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Unraid>, const boost::json::value &jvRoot);
boost::json::result_for<PayItForward, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<PayItForward>,
const boost::json::value &jvRoot);
boost::json::result_for<Announcement, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Announcement>,
const boost::json::value &jvRoot);
boost::json::result_for<CharityDonationAmount, boost::json::value>::type
tag_invoke(boost::json::try_value_to_tag<CharityDonationAmount>,
const boost::json::value &jvRoot);
boost::json::result_for<CharityDonation, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<CharityDonation>,
const boost::json::value &jvRoot);
boost::json::result_for<BitsBadgeTier, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<BitsBadgeTier>,
const boost::json::value &jvRoot);
boost::json::result_for<Message, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Message>, const boost::json::value &jvRoot);
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_chat_notification::v1
@@ -0,0 +1,452 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include "twitch-eventsub-ws/string.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::channel_moderate::v2 {
/* follower mode enabled
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"followers","followers":{"follow_duration_minutes":0},"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* follower mode disabled
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"followersoff","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* follower mode set to 7d
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"followers","followers":{"follow_duration_minutes":10080},"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Followers {
int followDurationMinutes;
};
/* slow mode set to 30s
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"slow","followers":null,"slow":{"wait_time_seconds":30},"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* slow mode off
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"slowoff","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Slow {
int waitTimeSeconds;
};
/* User is VIP'ed
{
"subscription": {
"id": "ef1d3e66-0001-4cdc-8e74-8746f9243704",
"status": "enabled",
"type": "channel.moderate",
"version": "2",
"condition": {
"broadcaster_user_id": "11148817",
"moderator_user_id": "117166826"
},
"transport": {
"method": "websocket",
"session_id": "AgoQBV1S6bGuR7e2hs04k4J1EhIGY2VsbC1j"
},
"created_at": "2025-02-01T11:55:30.114060889Z",
"cost": 0
},
"event": {
"broadcaster_user_id": "11148817",
"broadcaster_user_login": "pajlada",
"broadcaster_user_name": "pajlada",
"source_broadcaster_user_id": null,
"source_broadcaster_user_login": null,
"source_broadcaster_user_name": null,
"moderator_user_id": "11148817",
"moderator_user_login": "pajlada",
"moderator_user_name": "pajlada",
"action": "vip",
"followers": null,
"slow": null,
"vip": {
"user_id": "159849156",
"user_login": "bajlada",
"user_name": "BajLada"
},
"unvip": null,
"mod": null,
"unmod": null,
"ban": null,
"unban": null,
"timeout": null,
"untimeout": null,
"raid": null,
"unraid": null,
"delete": null,
"automod_terms": null,
"unban_request": null,
"warn": null,
"shared_chat_ban": null,
"shared_chat_unban": null,
"shared_chat_timeout": null,
"shared_chat_untimeout": null,
"shared_chat_delete": null
}
}
*/
/// json_transform=snake_case
struct Vip {
String userID;
String userLogin;
String userName;
};
/* user is unvipped
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"unvip","followers":null,"slow":null,"vip":null,"unvip":{"user_id":"159849156","user_login":"bajlada","user_name":"BajLada"},"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Unvip {
String userID;
String userLogin;
String userName;
};
/* user is modded
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"mod","followers":null,"slow":null,"vip":null,"unvip":null,"mod":{"user_id":"159849156","user_login":"bajlada","user_name":"BajLada"},"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Mod {
std::string userID;
std::string userLogin;
std::string userName;
};
/* user is unmodded
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"unmod","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":{"user_id":"159849156","user_login":"bajlada","user_name":"BajLada"},"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Unmod {
std::string userID;
std::string userLogin;
std::string userName;
};
/* user is banned with reason
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"ban","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123","reason":"this is the reason"},"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* user is banned without reason
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"ban","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123","reason":""},"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Ban {
std::string userID;
std::string userLogin;
std::string userName;
// TODO: Verify that we handle null here
std::string reason;
};
/* user is unbanned
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"unban","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123"},"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Unban {
std::string userID;
std::string userLogin;
std::string userName;
};
/* user is timed out without reason
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"timeout","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123","reason":"","expires_at":"2025-02-01T12:11:02.684499409Z"},"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* user is timed out with reason
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"timeout","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123","reason":"this is the reason","expires_at":"2025-02-01T12:11:15.859552916Z"},"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Timeout {
std::string userID;
std::string userLogin;
std::string userName;
// TODO: Verify that we handle null here
std::string reason;
// TODO: This should be a timestamp?
std::string expiresAt;
};
/* user is untimeouted
{"subscription":{"id":"86d99e53-2837-40cf-bc6e-c6e00698919c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQZgfnyKxXQ32hpzCWF4aCGBIGY2VsbC1j"},"created_at":"2025-02-01T12:02:16.005321321Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"untimeout","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":{"user_id":"70948394","user_login":"weeb123","user_name":"WEEB123"},"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Untimeout {
std::string userID;
std::string userLogin;
std::string userName;
};
/* channel is raided (from bajlada to pajlada)
{"subscription":{"id":"e7b45c7a-9b4d-4101-8d7d-92e8945c26fa","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"159849156","moderator_user_id":"159849156"},"transport":{"method":"websocket","session_id":"AgoQEPRIfB3SQTCSkJM2zznvNxIGY2VsbC1j"},"created_at":"2025-02-01T12:12:45.685831769Z","cost":0},"event":{"broadcaster_user_id":"159849156","broadcaster_user_login":"bajlada","broadcaster_user_name":"BajLada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"159849156","moderator_user_login":"bajlada","moderator_user_name":"BajLada","action":"raid","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":{"user_id":"11148817","user_login":"pajlada","user_name":"pajlada","viewer_count":0},"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Raid {
std::string userID;
std::string userLogin;
std::string userName;
int viewerCount;
};
/* raid from bajlada to pajlada was cancelled
{"subscription":{"id":"e7b45c7a-9b4d-4101-8d7d-92e8945c26fa","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"159849156","moderator_user_id":"159849156"},"transport":{"method":"websocket","session_id":"AgoQEPRIfB3SQTCSkJM2zznvNxIGY2VsbC1j"},"created_at":"2025-02-01T12:12:45.685831769Z","cost":0},"event":{"broadcaster_user_id":"159849156","broadcaster_user_login":"bajlada","broadcaster_user_name":"BajLada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"159849156","moderator_user_login":"bajlada","moderator_user_name":"BajLada","action":"unraid","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":{"user_id":"11148817","user_login":"pajlada","user_name":"pajlada"},"delete":null,"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Unraid {
std::string userID;
std::string userLogin;
std::string userName;
};
/* message deleted
{"subscription":{"id":"4284c08c-402a-43a8-8537-1e75f38f562c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.083098352Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"delete","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":{"user_id":"232490245","user_login":"namtheweebs","user_name":"NaMTheWeebs","message_id":"6c9bd28b-779b-4607-b66f-5c336e1ae29e","message_body":"bajlada raid NomNom"},"automod_terms":null,"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Delete {
std::string userID;
std::string userLogin;
std::string userName;
std::string messageID;
std::string messageBody;
};
/* automodded message approved
{"subscription":{"id":"0ccc8f11-7e77-40cf-84a9-25ab934c30fb","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"117166826","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.092673205Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"117166826","moderator_user_login":"testaccount_420","moderator_user_name":"테스트계정420","action":"add_permitted_term","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":{"action":"add","list":"permitted","terms":["cock cock cock penis sex cock"],"from_automod":true},"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* automodded message denied
{"subscription":{"id":"0ccc8f11-7e77-40cf-84a9-25ab934c30fb","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"117166826","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.092673205Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"117166826","moderator_user_login":"testaccount_420","moderator_user_name":"테스트계정420","action":"add_blocked_term","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":{"action":"add","list":"blocked","terms":["boobies"],"from_automod":true},"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
{"subscription":{"id":"0ccc8f11-7e77-40cf-84a9-25ab934c30fb","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"117166826","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.092673205Z","cost":0},"event":{"broadcaster_user_id":"117166826","broadcaster_user_login":"testaccount_420","broadcaster_user_name":"테스트계정420","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"117166826","moderator_user_login":"testaccount_420","moderator_user_name":"테스트계정420","action":"add_blocked_term","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":{"action":"add","list":"blocked","terms":["cock cock cock penis sex cock penis penis cock"],"from_automod":true},"unban_request":null,"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct AutomodTerms {
// either add or remove
std::string action;
// either blocked or permitted
std::string list;
std::vector<std::string> terms;
bool fromAutomod;
};
/* unban request approved
{"subscription":{"id":"4284c08c-402a-43a8-8537-1e75f38f562c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.083098352Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"approve_unban_request","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":{"is_approved":true,"user_id":"159849156","user_login":"bajlada","user_name":"BajLada","moderator_message":"you have been granted mercy"},"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* unban request denied
{"subscription":{"id":"4284c08c-402a-43a8-8537-1e75f38f562c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.083098352Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"deny_unban_request","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":{"is_approved":false,"user_id":"975285839","user_login":"selenatormapguy","user_name":"selenatormapguy","moderator_message":""},"warn":null,"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct UnbanRequest {
bool isApproved;
std::string userID;
std::string userLogin;
std::string userName;
std::string moderatorMessage;
};
/* freetext warn from chatterino
{"subscription":{"id":"4284c08c-402a-43a8-8537-1e75f38f562c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.083098352Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"warn","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":{"user_id":"159849156","user_login":"bajlada","user_name":"BajLada","reason":"this is a test warning from chatterino","chat_rules_cited":null},"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/* warning from web ui
{"subscription":{"id":"4284c08c-402a-43a8-8537-1e75f38f562c","status":"enabled","type":"channel.moderate","version":"2","condition":{"broadcaster_user_id":"11148817","moderator_user_id":"117166826"},"transport":{"method":"websocket","session_id":"AgoQfS3RYz3MSqOophais4HEjxIGY2VsbC1j"},"created_at":"2025-02-01T12:15:13.083098352Z","cost":0},"event":{"broadcaster_user_id":"11148817","broadcaster_user_login":"pajlada","broadcaster_user_name":"pajlada","source_broadcaster_user_id":null,"source_broadcaster_user_login":null,"source_broadcaster_user_name":null,"moderator_user_id":"11148817","moderator_user_login":"pajlada","moderator_user_name":"pajlada","action":"warn","followers":null,"slow":null,"vip":null,"unvip":null,"mod":null,"unmod":null,"ban":null,"unban":null,"timeout":null,"untimeout":null,"raid":null,"unraid":null,"delete":null,"automod_terms":null,"unban_request":null,"warn":{"user_id":"159849156","user_login":"bajlada","user_name":"BajLada","reason":"and a custom reason","chat_rules_cited":["Rule 2"]},"shared_chat_ban":null,"shared_chat_unban":null,"shared_chat_timeout":null,"shared_chat_untimeout":null,"shared_chat_delete":null}}
*/
/// json_transform=snake_case
struct Warn {
std::string userID;
std::string userLogin;
std::string userName;
std::string reason;
// TODO: Verify we handle null for this as an empty vector
std::vector<std::string> chatRulesCited;
};
/// json_transform=snake_case
enum class Action : uint8_t {
Ban,
Timeout,
Unban,
Untimeout,
Clear,
Emoteonly,
Emoteonlyoff,
Followers,
Followersoff,
Uniquechat,
Uniquechatoff,
Slow,
Slowoff,
Subscribers,
Subscribersoff,
Unraid,
/// json_rename=delete
DeleteMessage,
/// clangd currently "inherits" all future comments to all future enum constants
/// so after using something like json_rename we need to ensure it doesn't spread
Unvip,
Vip,
Raid,
AddBlockedTerm,
AddPermittedTerm,
RemoveBlockedTerm,
RemovePermittedTerm,
Mod,
Unmod,
ApproveUnbanRequest,
DenyUnbanRequest,
Warn,
SharedChatBan,
SharedChatTimeout,
SharedChatUnban,
SharedChatUntimeout,
SharedChatDelete,
};
/// json_transform=snake_case
struct Event {
/// User ID (e.g. 117166826) of the user who's channel the event took place in
String broadcasterUserID;
/// User Login (e.g. testaccount_420) of the user who's channel the event took place in
String broadcasterUserLogin;
/// User Name (e.g. 테스트계정420) of the user who's channel the event took place in
String broadcasterUserName;
/// For Shared Chat events, the user ID (e.g. 117166826) of the user who's channel the event took place in
std::optional<std::string> sourceBroadcasterUserID;
/// For Shared Chat events, the user Login (e.g. testaccount_420) of the user who's channel the event took place in
std::optional<std::string> sourceBroadcasterUserLogin;
/// For Shared Chat events, the user Name (e.g. 테스트계정420) of the user who's channel the event took place in
std::optional<std::string> sourceBroadcasterUserName;
/// User ID (e.g. 117166826) of the user who took the action
String moderatorUserID;
/// User Login (e.g. testaccount_420) of the user who took the action
String moderatorUserLogin;
/// User Name (e.g. 테스트계정420) of the user who took the action
String moderatorUserName;
// TODO: enum?
/// The action that took place (e.g. "warn" or "ban")
Action action;
std::optional<Followers> followers;
std::optional<Slow> slow;
std::optional<Vip> vip;
std::optional<Unvip> unvip;
std::optional<Unmod> unmod;
std::optional<Ban> ban;
std::optional<Unban> unban;
std::optional<Timeout> timeout;
std::optional<Untimeout> untimeout;
std::optional<Raid> raid;
std::optional<Unraid> unraid;
/// json_rename=delete
std::optional<Delete> deleteMessage;
std::optional<AutomodTerms> automodTerms;
std::optional<UnbanRequest> unbanRequest;
std::optional<Warn> warn;
std::optional<Ban> sharedChatBan;
std::optional<Unban> sharedChatUnban;
std::optional<Timeout> sharedChatTimeout;
std::optional<Untimeout> sharedChatUntimeout;
std::optional<Delete> sharedChatDelete;
};
struct Payload {
subscription::Subscription subscription;
Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Action, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Action>, const boost::json::value &jvRoot);
boost::json::result_for<Followers, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Followers>, const boost::json::value &jvRoot);
boost::json::result_for<Slow, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Slow>, const boost::json::value &jvRoot);
boost::json::result_for<Vip, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Vip>, const boost::json::value &jvRoot);
boost::json::result_for<Unvip, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Unvip>, const boost::json::value &jvRoot);
boost::json::result_for<Mod, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Mod>, const boost::json::value &jvRoot);
boost::json::result_for<Unmod, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Unmod>, const boost::json::value &jvRoot);
boost::json::result_for<Ban, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Ban>, const boost::json::value &jvRoot);
boost::json::result_for<Unban, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Unban>, const boost::json::value &jvRoot);
boost::json::result_for<Timeout, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Timeout>, const boost::json::value &jvRoot);
boost::json::result_for<Untimeout, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Untimeout>, const boost::json::value &jvRoot);
boost::json::result_for<Raid, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Raid>, const boost::json::value &jvRoot);
boost::json::result_for<Unraid, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Unraid>, const boost::json::value &jvRoot);
boost::json::result_for<Delete, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Delete>, const boost::json::value &jvRoot);
boost::json::result_for<AutomodTerms, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<AutomodTerms>,
const boost::json::value &jvRoot);
boost::json::result_for<UnbanRequest, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<UnbanRequest>,
const boost::json::value &jvRoot);
boost::json::result_for<Warn, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Warn>, const boost::json::value &jvRoot);
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_moderate::v2
@@ -0,0 +1,49 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::channel_update::v1 {
/// json_transform=snake_case
struct Event {
// The broadcaster's user ID
const std::string broadcasterUserID;
// The broadcaster's user login
const std::string broadcasterUserLogin;
// The broadcaster's user display name
const std::string broadcasterUserName;
// The channel's stream title
const std::string title;
// The channel's broadcast language
const std::string language;
// The channels category ID
const std::string categoryID;
// The category name
const std::string categoryName;
// A boolean identifying whether the channel is flagged as mature
const bool isMature;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_update::v1
@@ -0,0 +1,36 @@
#pragma once
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::session_welcome {
/*
{
"metadata": ...
"payload": {
"session": {
"id": "44f8cbce_c7ee958a",
"status": "connected",
"keepalive_timeout_seconds": 10,
"reconnect_url": null,
"connected_at": "2023-05-14T12:31:47.995262791Z"
}
}
}
*/
/// json_inner=session
struct Payload {
const std::string id;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::session_welcome
@@ -0,0 +1,35 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::stream_offline::v1 {
/// json_transform=snake_case
struct Event {
// The broadcaster's user ID
const std::string broadcasterUserID;
// The broadcaster's user login
const std::string broadcasterUserLogin;
// The broadcaster's user display name
const std::string broadcasterUserName;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::stream_offline::v1
@@ -0,0 +1,45 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::stream_online::v1 {
/// json_transform=snake_case
struct Event {
// The ID of the stream
const std::string id;
// The broadcaster's user ID
const std::string broadcasterUserID;
// The broadcaster's user login
const std::string broadcasterUserLogin;
// The broadcaster's user display name
const std::string broadcasterUserName;
// The stream type (e.g. live, playlist, watch_party)
const std::string type;
// The timestamp at which the stream went online
// TODO: chronofy?
const std::string startedAt;
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot);
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::stream_online::v1
@@ -0,0 +1,66 @@
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::subscription {
/*
{
"metadata": ...,
"payload": {
"subscription": {
"id": "4aa632e0-fca3-590b-e981-bbd12abdb3fe",
"status": "enabled",
"type": "channel.ban",
"version": "1",
"condition": {
"broadcaster_user_id": "74378979"
},
"transport": {
"method": "websocket",
"session_id": "38de428e_b11f07be"
},
"created_at": "2023-05-20T12:30:55.518375571Z",
"cost": 0
},
...
}
}
*/
/// json_transform=snake_case
struct Transport {
const std::string method;
const std::string sessionID;
};
/// json_transform=snake_case
struct Subscription {
const std::string id;
const std::string status;
const std::string type;
const std::string version;
// TODO: How do we map condition here? vector of key/value pairs?
const Transport transport;
// TODO: chronofy?
const std::string createdAt;
const int cost;
};
// DESERIALIZATION DEFINITION START
boost::json::result_for<Transport, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Transport>, const boost::json::value &jvRoot);
boost::json::result_for<Subscription, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Subscription>,
const boost::json::value &jvRoot);
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::subscription
@@ -0,0 +1,70 @@
#pragma once
#include <boost/asio.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/ssl.hpp>
#include <boost/json.hpp>
namespace chatterino::eventsub::lib {
class Listener;
/**
* handleMessage takes the incoming message in the buffer, parses it
* as JSON then forwards it to the listener, if applicable.
*
* This is called from the Session, and is only provided if you are interested
* in building your own boost asio framework thing
**/
boost::system::error_code handleMessage(
std::unique_ptr<Listener> &listener,
const boost::beast::flat_buffer &buffer);
// Sends a WebSocket message and prints the response
class Session : public std::enable_shared_from_this<Session>
{
boost::asio::ip::tcp::resolver resolver;
boost::beast::websocket::stream<
boost::beast::ssl_stream<boost::beast::tcp_stream>>
ws;
boost::beast::flat_buffer buffer;
std::string host;
std::string port;
std::string path;
std::string userAgent;
std::unique_ptr<Listener> listener;
public:
// Resolver and socket require an io_context
explicit Session(boost::asio::io_context &ioc,
boost::asio::ssl::context &ctx,
std::unique_ptr<Listener> listener);
// Start the asynchronous operation
void run(std::string _host, std::string _port, std::string _path,
std::string _userAgent);
void close();
Listener *getListener();
private:
void onResolve(boost::beast::error_code ec,
boost::asio::ip::tcp::resolver::results_type results);
void onConnect(
boost::beast::error_code ec,
boost::asio::ip::tcp::resolver::results_type::endpoint_type ep);
void onSSLHandshake(boost::beast::error_code ec);
void onHandshake(boost::beast::error_code ec);
void onRead(boost::beast::error_code ec, std::size_t bytes_transferred);
void onClose(boost::beast::error_code ec);
};
} // namespace chatterino::eventsub::lib
@@ -0,0 +1,67 @@
#pragma once
#include <boost/json.hpp>
#include <QString>
#include <string>
#include <variant>
namespace chatterino::eventsub::lib {
template <class>
constexpr bool DEPENDENT_FALSE = false; // workaround before CWG2518/P2593R1
/// String is a struct that holds either an std::string or a QString
///
/// The intended use is for it to receive an std::string that has been built by
/// boost::json, and once it's been passed into a GUI appliciaton, it can use
/// the `qt` function to convert the backing string to a QString,
/// while we ensure the conversion is only done once.
struct String {
explicit String(std::string &&v)
: backingString(std::move(v))
{
}
~String() = default;
String(const String &s) = delete;
String(String &&s) = default;
String &operator=(const String &) = delete;
String &operator=(String &&) = default;
/// Returns the string as a QString, modifying the backing string to ensure
/// the copy only happens once.
QString qt() const
{
return std::visit(
[this](auto &&arg) -> QString {
using T = std::decay_t<std::remove_cvref_t<decltype(arg)>>;
if constexpr (std::is_same_v<T, std::string>)
{
auto qtString = QString::fromStdString(arg);
this->backingString = qtString;
return qtString;
}
else if constexpr (std::is_same_v<T, QString>)
{
return arg;
}
else
{
static_assert(DEPENDENT_FALSE<T>,
"unknown type in variant");
}
},
this->backingString);
}
private:
mutable std::variant<std::string, QString> backingString;
};
boost::json::result_for<String, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<String>, const boost::json::value &jvRoot);
} // namespace chatterino::eventsub::lib
+5
View File
@@ -0,0 +1,5 @@
[tool.black]
line-length = 120
[tool.ruff]
line-length = 120
+119
View File
@@ -0,0 +1,119 @@
set(SOURCE_FILES
session.cpp
chrono.cpp
json.cpp
string.cpp
payloads/subscription.cpp
payloads/session-welcome.cpp
# Subscription types
payloads/channel-ban-v1.cpp
payloads/stream-online-v1.cpp
payloads/stream-offline-v1.cpp
payloads/channel-update-v1.cpp
payloads/channel-chat-notification-v1.cpp
payloads/channel-chat-message-v1.cpp
payloads/channel-moderate-v2.cpp
# Add your new subscription type source file above this line
messages/metadata.cpp
)
add_library(${PROJECT_NAME} STATIC ${SOURCE_FILES})
# Generate source groups for use in IDEs
# source_group(TREE ${CMAKE_SOURCE_DIR} FILES ${SOURCE_FILES})
target_include_directories(${PROJECT_NAME} PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include")
target_link_libraries(${PROJECT_NAME}
PUBLIC
Qt${MAJOR_QT_VERSION}::Core
Boost::headers
OpenSSL::SSL
OpenSSL::Crypto
)
target_compile_definitions(${PROJECT_NAME} PUBLIC
$<$<BOOL:${MSVC}>:BOOST_JSON_NO_LIB>
$<$<BOOL:${MSVC}>:BOOST_CONTAINER_NO_LIB>
)
# Hack to get the include directories from Python
get_target_property(_inc_dirs ${PROJECT_NAME} INCLUDE_DIRECTORIES)
list(APPEND _inc_dirs ${Boost_INCLUDE_DIRS})
list(APPEND _inc_dirs ${OPENSSL_INCLUDE_DIR})
list(JOIN _inc_dirs ";" _inc_dir)
add_custom_target(_ast_includes echo "@@INCLUDE_DIRS=${_inc_dir}")
if (MSVC)
# Add bigobj
target_compile_options(${PROJECT_NAME} PRIVATE /EHsc /bigobj)
# Change flags for RelWithDebInfo
# Default: "/debug /INCREMENTAL"
# Changes:
# - Disable incremental linking to reduce padding
# - Enable all optimizations - by default when /DEBUG is specified,
# these optimizations will be disabled. We need /DEBUG to generate a PDB.
# See https://gitlab.kitware.com/cmake/cmake/-/issues/20812 for more details.
set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO "/DEBUG /INCREMENTAL:NO /OPT:REF,ICF,LBR")
# Use the function inlining level from 'Release' mode (2).
string(REPLACE "/Ob1" "/Ob2" CMAKE_CXX_FLAGS_RELWITHDEBINFO "${CMAKE_CXX_FLAGS_RELWITHDEBINFO}")
# Configure warnings
# Someone adds /W3 before we add /W4.
# This makes sure, only /W4 is specified.
string(REPLACE "/W3" "/W4" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
target_compile_options(${PROJECT_NAME} PUBLIC
/W4
# 5038 - warnings about initialization order
/w15038
# 4855 - implicit capture of 'this' via '[=]' is deprecated
/w14855
# Disable the following warnings (see reasoning above)
/wd4505
/wd4100
/wd4267
)
# Disable min/max macros from Windows.h
target_compile_definitions(${PROJECT_NAME} PUBLIC NOMINMAX)
else ()
target_compile_options(${PROJECT_NAME} PUBLIC
-Wall
# Disable the following warnings
-Wno-unused-function
-Wno-switch
-Wno-deprecated-declarations
-Wno-sign-compare
-Wno-unused-variable
# Disabling strict-aliasing warnings for now, although we probably want to re-enable this in the future
-Wno-strict-aliasing
-Werror=return-type
-Werror=reorder
)
if (CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(${PROJECT_NAME} PUBLIC
-Wno-unused-local-typedef
-Wno-unused-private-field
-Werror=inconsistent-missing-override
-Werror=final-dtor-non-final-class
-Werror=ambiguous-reversed-operator
)
else ()
target_compile_options(${PROJECT_NAME} PUBLIC
-Wno-class-memaccess
)
endif()
endif ()
+27
View File
@@ -0,0 +1,27 @@
#include "twitch-eventsub-ws/chrono.hpp"
#include "twitch-eventsub-ws/date.h"
#include <sstream>
namespace chatterino::eventsub::lib {
boost::json::result_for<std::chrono::system_clock::time_point,
boost::json::value>::type
tag_invoke(
boost::json::try_value_to_tag<std::chrono::system_clock::time_point>,
const boost::json::value &jvRoot, const AsISO8601 &)
{
const auto raw = boost::json::try_value_to<std::string>(jvRoot);
if (raw.has_error())
{
return raw.error();
}
std::chrono::system_clock::time_point tp;
std::istringstream in{*raw};
in >> date::parse("%FT%TZ", tp);
return tp;
}
} // namespace chatterino::eventsub::lib
+1
View File
@@ -0,0 +1 @@
#include <boost/json/src.hpp>
@@ -0,0 +1,109 @@
#include "twitch-eventsub-ws/messages/metadata.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::messages {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Metadata, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Metadata>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Metadata must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvmessageID = root.if_contains("message_id");
if (jvmessageID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_messageID{"Missing required key message_id"};
return boost::system::error_code{129, error_missing_field_messageID};
}
auto messageID = boost::json::try_value_to<std::string>(*jvmessageID);
if (messageID.has_error())
{
return messageID.error();
}
const auto *jvmessageType = root.if_contains("message_type");
if (jvmessageType == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_messageType{
"Missing required key message_type"};
return boost::system::error_code{129, error_missing_field_messageType};
}
auto messageType = boost::json::try_value_to<std::string>(*jvmessageType);
if (messageType.has_error())
{
return messageType.error();
}
const auto *jvmessageTimestamp = root.if_contains("message_timestamp");
if (jvmessageTimestamp == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_messageTimestamp{
"Missing required key message_timestamp"};
return boost::system::error_code{129,
error_missing_field_messageTimestamp};
}
auto messageTimestamp =
boost::json::try_value_to<std::string>(*jvmessageTimestamp);
if (messageTimestamp.has_error())
{
return messageTimestamp.error();
}
std::optional<std::string> subscriptionType = std::nullopt;
const auto *jvsubscriptionType = root.if_contains("subscription_type");
if (jvsubscriptionType != nullptr && !jvsubscriptionType->is_null())
{
auto tsubscriptionType =
boost::json::try_value_to<std::string>(*jvsubscriptionType);
if (tsubscriptionType.has_error())
{
return tsubscriptionType.error();
}
subscriptionType = std::move(tsubscriptionType.value());
}
std::optional<std::string> subscriptionVersion = std::nullopt;
const auto *jvsubscriptionVersion =
root.if_contains("subscription_version");
if (jvsubscriptionVersion != nullptr && !jvsubscriptionVersion->is_null())
{
auto tsubscriptionVersion =
boost::json::try_value_to<std::string>(*jvsubscriptionVersion);
if (tsubscriptionVersion.has_error())
{
return tsubscriptionVersion.error();
}
subscriptionVersion = std::move(tsubscriptionVersion.value());
}
return Metadata{
.messageID = std::move(messageID.value()),
.messageType = std::move(messageType.value()),
.messageTimestamp = std::move(messageTimestamp.value()),
.subscriptionType = std::move(subscriptionType),
.subscriptionVersion = std::move(subscriptionVersion),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::messages
@@ -0,0 +1,127 @@
# Adding a new subscription type
The example will use `channel.update` on version `1`.
## Create the header & source files
Replace `channel-update`/`channel_update` with your dashed & underscored subscription names
Header file `include/twitch-eventsub-ws/payloads/channel-update-v1.hpp`:
```c++
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::channel_update::v1 {
/// json_transform=snake_case
struct Event {
// TODO: Fill in your subscription-specific event here
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::channel_update::v1
```
Source file:
```c++
#include "twitch-eventsub-ws/payloads/channel-update-v1.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::channel_update::v1 {
// DESERIALIZATION IMPLEMENTATION START
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::channel_update::v1
```
## Run the generation script
In the root dir of the repostitory, type `make` in your terminal.
If you can't do that, run the `generate-and-replace-dir.py` script manually (e.g. `./ast/generate-and-replace-dir.py ./src/payloads`)
## Add the source file to `src/CMakeLists.txt`
Look for the `# Add your new subscription type source file above this line` comment and add the source file above that line.
In my example, I added the following line:
`payloads/channel-update-v1.cpp`
## Add a virtual method to the Listener class in `include/twitch-eventsub-ws/listener.hpp`
Look for the `// Add your new subscription types above this line` comment and add your definition above that line.
In my example, I added the following code:
```c++
virtual void onChannelUpdate(
messages::Metadata metadata,
payload::channel_update::v1::Payload payload) = 0;
```
You also need to add an include to your header file
In my example, I added the following code at the top of the file:
```c++
#include "twitch-eventsub-ws/payloads/channel-update-v1.hpp"
```
## Add a handler for the subscription type in `src/session.cpp`
Look for the `// Add your new subscription types above this line` comment and add your code above that line.
In my example, I added the following code:
```c++
{
{"channel.update", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<eventsub::payload::channel_update::v1::Payload>(
jv);
if (!oPayload)
{
return;
}
listener->onChannelUpdate(metadata, *oPayload);
},
},
```
## Make the test code work in `example/main.cpp`
Look for the `// Add your new subscription types above this line` comment and add your code above that line.
In my example, I added the following code:
```c++
void onChannelUpdate(messages::Metadata metadata,
payload::channel_update::v1::Payload payload) override
{
std::cout << "Channel update event!\n";
}
```
Full PR that does this: https://github.com/pajlada/beast-websocket-client/pull/19/files
TODO: Add step for how to verify this using the Twitch CLI, realistically though this will differ from subscription to subscription. Some are simpler than others
@@ -0,0 +1,317 @@
#include "twitch-eventsub-ws/payloads/channel-ban-v1.hpp"
#include "twitch-eventsub-ws/chrono.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::channel_ban::v1 {
std::chrono::system_clock::duration Event::timeoutDuration() const
{
if (!this->endsAt)
{
return {};
}
return *this->endsAt - this->bannedAt;
}
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Event must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id");
if (jvbroadcasterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserID{
"Missing required key broadcaster_user_id"};
return boost::system::error_code{129,
error_missing_field_broadcasterUserID};
}
auto broadcasterUserID =
boost::json::try_value_to<std::string>(*jvbroadcasterUserID);
if (broadcasterUserID.has_error())
{
return broadcasterUserID.error();
}
const auto *jvbroadcasterUserLogin =
root.if_contains("broadcaster_user_login");
if (jvbroadcasterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserLogin{
"Missing required key broadcaster_user_login"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserLogin};
}
auto broadcasterUserLogin =
boost::json::try_value_to<std::string>(*jvbroadcasterUserLogin);
if (broadcasterUserLogin.has_error())
{
return broadcasterUserLogin.error();
}
const auto *jvbroadcasterUserName =
root.if_contains("broadcaster_user_name");
if (jvbroadcasterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserName{
"Missing required key broadcaster_user_name"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserName};
}
auto broadcasterUserName =
boost::json::try_value_to<std::string>(*jvbroadcasterUserName);
if (broadcasterUserName.has_error())
{
return broadcasterUserName.error();
}
const auto *jvmoderatorUserID = root.if_contains("moderator_user_id");
if (jvmoderatorUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_moderatorUserID{
"Missing required key moderator_user_id"};
return boost::system::error_code{129,
error_missing_field_moderatorUserID};
}
auto moderatorUserID =
boost::json::try_value_to<std::string>(*jvmoderatorUserID);
if (moderatorUserID.has_error())
{
return moderatorUserID.error();
}
const auto *jvmoderatorUserLogin = root.if_contains("moderator_user_login");
if (jvmoderatorUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_moderatorUserLogin{
"Missing required key moderator_user_login"};
return boost::system::error_code{
129, error_missing_field_moderatorUserLogin};
}
auto moderatorUserLogin =
boost::json::try_value_to<std::string>(*jvmoderatorUserLogin);
if (moderatorUserLogin.has_error())
{
return moderatorUserLogin.error();
}
const auto *jvmoderatorUserName = root.if_contains("moderator_user_name");
if (jvmoderatorUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_moderatorUserName{
"Missing required key moderator_user_name"};
return boost::system::error_code{129,
error_missing_field_moderatorUserName};
}
auto moderatorUserName =
boost::json::try_value_to<std::string>(*jvmoderatorUserName);
if (moderatorUserName.has_error())
{
return moderatorUserName.error();
}
const auto *jvuserID = root.if_contains("user_id");
if (jvuserID == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_userID{
"Missing required key user_id"};
return boost::system::error_code{129, error_missing_field_userID};
}
auto userID = boost::json::try_value_to<std::string>(*jvuserID);
if (userID.has_error())
{
return userID.error();
}
const auto *jvuserLogin = root.if_contains("user_login");
if (jvuserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_userLogin{"Missing required key user_login"};
return boost::system::error_code{129, error_missing_field_userLogin};
}
auto userLogin = boost::json::try_value_to<std::string>(*jvuserLogin);
if (userLogin.has_error())
{
return userLogin.error();
}
const auto *jvuserName = root.if_contains("user_name");
if (jvuserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_userName{"Missing required key user_name"};
return boost::system::error_code{129, error_missing_field_userName};
}
auto userName = boost::json::try_value_to<std::string>(*jvuserName);
if (userName.has_error())
{
return userName.error();
}
const auto *jvreason = root.if_contains("reason");
if (jvreason == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_reason{
"Missing required key reason"};
return boost::system::error_code{129, error_missing_field_reason};
}
auto reason = boost::json::try_value_to<std::string>(*jvreason);
if (reason.has_error())
{
return reason.error();
}
const auto *jvisPermanent = root.if_contains("is_permanent");
if (jvisPermanent == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_isPermanent{
"Missing required key is_permanent"};
return boost::system::error_code{129, error_missing_field_isPermanent};
}
auto isPermanent = boost::json::try_value_to<bool>(*jvisPermanent);
if (isPermanent.has_error())
{
return isPermanent.error();
}
const auto *jvbannedAt = root.if_contains("banned_at");
if (jvbannedAt == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_bannedAt{"Missing required key banned_at"};
return boost::system::error_code{129, error_missing_field_bannedAt};
}
auto bannedAt =
boost::json::try_value_to<std::chrono::system_clock::time_point>(
*jvbannedAt, AsISO8601());
if (bannedAt.has_error())
{
return bannedAt.error();
}
std::optional<std::chrono::system_clock::time_point> endsAt = std::nullopt;
const auto *jvendsAt = root.if_contains("ends_at");
if (jvendsAt != nullptr && !jvendsAt->is_null())
{
auto tendsAt =
boost::json::try_value_to<std::chrono::system_clock::time_point>(
*jvendsAt, AsISO8601());
if (tendsAt.has_error())
{
return tendsAt.error();
}
endsAt = std::move(tendsAt.value());
}
return Event{
.broadcasterUserID = std::move(broadcasterUserID.value()),
.broadcasterUserLogin = std::move(broadcasterUserLogin.value()),
.broadcasterUserName = std::move(broadcasterUserName.value()),
.moderatorUserID = std::move(moderatorUserID.value()),
.moderatorUserLogin = std::move(moderatorUserLogin.value()),
.moderatorUserName = std::move(moderatorUserName.value()),
.userID = std::move(userID.value()),
.userLogin = std::move(userLogin.value()),
.userName = std::move(userName.value()),
.reason = std::move(reason.value()),
.isPermanent = std::move(isPermanent.value()),
.bannedAt = std::move(bannedAt.value()),
.endsAt = std::move(endsAt),
};
}
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsubscription = root.if_contains("subscription");
if (jvsubscription == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_subscription{
"Missing required key subscription"};
return boost::system::error_code{129, error_missing_field_subscription};
}
auto subscription =
boost::json::try_value_to<subscription::Subscription>(*jvsubscription);
if (subscription.has_error())
{
return subscription.error();
}
const auto *jvevent = root.if_contains("event");
if (jvevent == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_event{
"Missing required key event"};
return boost::system::error_code{129, error_missing_field_event};
}
auto event = boost::json::try_value_to<Event>(*jvevent);
if (event.has_error())
{
return event.error();
}
return Payload{
.subscription = std::move(subscription.value()),
.event = std::move(event.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::channel_ban::v1
@@ -0,0 +1,934 @@
#include "twitch-eventsub-ws/payloads/channel-chat-message-v1.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::channel_chat_message::v1 {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Badge, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Badge>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Badge must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsetID = root.if_contains("set_id");
if (jvsetID == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_setID{
"Missing required key set_id"};
return boost::system::error_code{129, error_missing_field_setID};
}
auto setID = boost::json::try_value_to<std::string>(*jvsetID);
if (setID.has_error())
{
return setID.error();
}
const auto *jvid = root.if_contains("id");
if (jvid == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_id{
"Missing required key id"};
return boost::system::error_code{129, error_missing_field_id};
}
auto id = boost::json::try_value_to<std::string>(*jvid);
if (id.has_error())
{
return id.error();
}
const auto *jvinfo = root.if_contains("info");
if (jvinfo == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_info{
"Missing required key info"};
return boost::system::error_code{129, error_missing_field_info};
}
auto info = boost::json::try_value_to<std::string>(*jvinfo);
if (info.has_error())
{
return info.error();
}
return Badge{
.setID = std::move(setID.value()),
.id = std::move(id.value()),
.info = std::move(info.value()),
};
}
boost::json::result_for<Cheermote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Cheermote>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Cheermote must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvprefix = root.if_contains("prefix");
if (jvprefix == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_prefix{
"Missing required key prefix"};
return boost::system::error_code{129, error_missing_field_prefix};
}
auto prefix = boost::json::try_value_to<std::string>(*jvprefix);
if (prefix.has_error())
{
return prefix.error();
}
const auto *jvbits = root.if_contains("bits");
if (jvbits == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_bits{
"Missing required key bits"};
return boost::system::error_code{129, error_missing_field_bits};
}
auto bits = boost::json::try_value_to<int>(*jvbits);
if (bits.has_error())
{
return bits.error();
}
const auto *jvtier = root.if_contains("tier");
if (jvtier == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_tier{
"Missing required key tier"};
return boost::system::error_code{129, error_missing_field_tier};
}
auto tier = boost::json::try_value_to<int>(*jvtier);
if (tier.has_error())
{
return tier.error();
}
return Cheermote{
.prefix = std::move(prefix.value()),
.bits = std::move(bits.value()),
.tier = std::move(tier.value()),
};
}
boost::json::result_for<Emote, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Emote>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Emote must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvid = root.if_contains("id");
if (jvid == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_id{
"Missing required key id"};
return boost::system::error_code{129, error_missing_field_id};
}
auto id = boost::json::try_value_to<std::string>(*jvid);
if (id.has_error())
{
return id.error();
}
const auto *jvemoteSetID = root.if_contains("emote_set_id");
if (jvemoteSetID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_emoteSetID{"Missing required key emote_set_id"};
return boost::system::error_code{129, error_missing_field_emoteSetID};
}
auto emoteSetID = boost::json::try_value_to<std::string>(*jvemoteSetID);
if (emoteSetID.has_error())
{
return emoteSetID.error();
}
const auto *jvownerID = root.if_contains("owner_id");
if (jvownerID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_ownerID{"Missing required key owner_id"};
return boost::system::error_code{129, error_missing_field_ownerID};
}
auto ownerID = boost::json::try_value_to<std::string>(*jvownerID);
if (ownerID.has_error())
{
return ownerID.error();
}
const auto *jvformat = root.if_contains("format");
if (jvformat == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_format{
"Missing required key format"};
return boost::system::error_code{129, error_missing_field_format};
}
const auto format =
boost::json::try_value_to<std::vector<std::string>>(*jvformat);
if (format.has_error())
{
return format.error();
}
return Emote{
.id = std::move(id.value()),
.emoteSetID = std::move(emoteSetID.value()),
.ownerID = std::move(ownerID.value()),
.format = format.value(),
};
}
boost::json::result_for<Mention, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Mention>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Mention must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvuserID = root.if_contains("user_id");
if (jvuserID == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_userID{
"Missing required key user_id"};
return boost::system::error_code{129, error_missing_field_userID};
}
auto userID = boost::json::try_value_to<std::string>(*jvuserID);
if (userID.has_error())
{
return userID.error();
}
const auto *jvuserName = root.if_contains("user_name");
if (jvuserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_userName{"Missing required key user_name"};
return boost::system::error_code{129, error_missing_field_userName};
}
auto userName = boost::json::try_value_to<std::string>(*jvuserName);
if (userName.has_error())
{
return userName.error();
}
const auto *jvuserLogin = root.if_contains("user_login");
if (jvuserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_userLogin{"Missing required key user_login"};
return boost::system::error_code{129, error_missing_field_userLogin};
}
auto userLogin = boost::json::try_value_to<std::string>(*jvuserLogin);
if (userLogin.has_error())
{
return userLogin.error();
}
return Mention{
.userID = std::move(userID.value()),
.userName = std::move(userName.value()),
.userLogin = std::move(userLogin.value()),
};
}
boost::json::result_for<MessageFragment, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<MessageFragment>,
const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"MessageFragment must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvtype = root.if_contains("type");
if (jvtype == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_type{
"Missing required key type"};
return boost::system::error_code{129, error_missing_field_type};
}
auto type = boost::json::try_value_to<std::string>(*jvtype);
if (type.has_error())
{
return type.error();
}
const auto *jvtext = root.if_contains("text");
if (jvtext == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_text{
"Missing required key text"};
return boost::system::error_code{129, error_missing_field_text};
}
auto text = boost::json::try_value_to<std::string>(*jvtext);
if (text.has_error())
{
return text.error();
}
std::optional<Cheermote> cheermote = std::nullopt;
const auto *jvcheermote = root.if_contains("cheermote");
if (jvcheermote != nullptr && !jvcheermote->is_null())
{
auto tcheermote = boost::json::try_value_to<Cheermote>(*jvcheermote);
if (tcheermote.has_error())
{
return tcheermote.error();
}
cheermote = std::move(tcheermote.value());
}
std::optional<Emote> emote = std::nullopt;
const auto *jvemote = root.if_contains("emote");
if (jvemote != nullptr && !jvemote->is_null())
{
auto temote = boost::json::try_value_to<Emote>(*jvemote);
if (temote.has_error())
{
return temote.error();
}
emote = std::move(temote.value());
}
std::optional<Mention> mention = std::nullopt;
const auto *jvmention = root.if_contains("mention");
if (jvmention != nullptr && !jvmention->is_null())
{
auto tmention = boost::json::try_value_to<Mention>(*jvmention);
if (tmention.has_error())
{
return tmention.error();
}
mention = std::move(tmention.value());
}
return MessageFragment{
.type = std::move(type.value()),
.text = std::move(text.value()),
.cheermote = std::move(cheermote),
.emote = std::move(emote),
.mention = std::move(mention),
};
}
boost::json::result_for<Message, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Message>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Message must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvtext = root.if_contains("text");
if (jvtext == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_text{
"Missing required key text"};
return boost::system::error_code{129, error_missing_field_text};
}
auto text = boost::json::try_value_to<std::string>(*jvtext);
if (text.has_error())
{
return text.error();
}
const auto *jvfragments = root.if_contains("fragments");
if (jvfragments == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_fragments{"Missing required key fragments"};
return boost::system::error_code{129, error_missing_field_fragments};
}
const auto fragments =
boost::json::try_value_to<std::vector<MessageFragment>>(*jvfragments);
if (fragments.has_error())
{
return fragments.error();
}
return Message{
.text = std::move(text.value()),
.fragments = fragments.value(),
};
}
boost::json::result_for<Cheer, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Cheer>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Cheer must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvbits = root.if_contains("bits");
if (jvbits == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_bits{
"Missing required key bits"};
return boost::system::error_code{129, error_missing_field_bits};
}
auto bits = boost::json::try_value_to<int>(*jvbits);
if (bits.has_error())
{
return bits.error();
}
return Cheer{
.bits = std::move(bits.value()),
};
}
boost::json::result_for<Reply, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Reply>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Reply must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvparentMessageID = root.if_contains("parent_message_id");
if (jvparentMessageID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_parentMessageID{
"Missing required key parent_message_id"};
return boost::system::error_code{129,
error_missing_field_parentMessageID};
}
auto parentMessageID =
boost::json::try_value_to<std::string>(*jvparentMessageID);
if (parentMessageID.has_error())
{
return parentMessageID.error();
}
const auto *jvparentUserID = root.if_contains("parent_user_id");
if (jvparentUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_parentUserID{
"Missing required key parent_user_id"};
return boost::system::error_code{129, error_missing_field_parentUserID};
}
auto parentUserID = boost::json::try_value_to<std::string>(*jvparentUserID);
if (parentUserID.has_error())
{
return parentUserID.error();
}
const auto *jvparentUserLogin = root.if_contains("parent_user_login");
if (jvparentUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_parentUserLogin{
"Missing required key parent_user_login"};
return boost::system::error_code{129,
error_missing_field_parentUserLogin};
}
auto parentUserLogin =
boost::json::try_value_to<std::string>(*jvparentUserLogin);
if (parentUserLogin.has_error())
{
return parentUserLogin.error();
}
const auto *jvparentUserName = root.if_contains("parent_user_name");
if (jvparentUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_parentUserName{
"Missing required key parent_user_name"};
return boost::system::error_code{129,
error_missing_field_parentUserName};
}
auto parentUserName =
boost::json::try_value_to<std::string>(*jvparentUserName);
if (parentUserName.has_error())
{
return parentUserName.error();
}
const auto *jvparentMessageBody = root.if_contains("parent_message_body");
if (jvparentMessageBody == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_parentMessageBody{
"Missing required key parent_message_body"};
return boost::system::error_code{129,
error_missing_field_parentMessageBody};
}
auto parentMessageBody =
boost::json::try_value_to<std::string>(*jvparentMessageBody);
if (parentMessageBody.has_error())
{
return parentMessageBody.error();
}
const auto *jvthreadMessageID = root.if_contains("thread_message_id");
if (jvthreadMessageID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_threadMessageID{
"Missing required key thread_message_id"};
return boost::system::error_code{129,
error_missing_field_threadMessageID};
}
auto threadMessageID =
boost::json::try_value_to<std::string>(*jvthreadMessageID);
if (threadMessageID.has_error())
{
return threadMessageID.error();
}
const auto *jvthreadUserID = root.if_contains("thread_user_id");
if (jvthreadUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_threadUserID{
"Missing required key thread_user_id"};
return boost::system::error_code{129, error_missing_field_threadUserID};
}
auto threadUserID = boost::json::try_value_to<std::string>(*jvthreadUserID);
if (threadUserID.has_error())
{
return threadUserID.error();
}
const auto *jvthreadUserLogin = root.if_contains("thread_user_login");
if (jvthreadUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_threadUserLogin{
"Missing required key thread_user_login"};
return boost::system::error_code{129,
error_missing_field_threadUserLogin};
}
auto threadUserLogin =
boost::json::try_value_to<std::string>(*jvthreadUserLogin);
if (threadUserLogin.has_error())
{
return threadUserLogin.error();
}
const auto *jvthreadUserName = root.if_contains("thread_user_name");
if (jvthreadUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_threadUserName{
"Missing required key thread_user_name"};
return boost::system::error_code{129,
error_missing_field_threadUserName};
}
auto threadUserName =
boost::json::try_value_to<std::string>(*jvthreadUserName);
if (threadUserName.has_error())
{
return threadUserName.error();
}
return Reply{
.parentMessageID = std::move(parentMessageID.value()),
.parentUserID = std::move(parentUserID.value()),
.parentUserLogin = std::move(parentUserLogin.value()),
.parentUserName = std::move(parentUserName.value()),
.parentMessageBody = std::move(parentMessageBody.value()),
.threadMessageID = std::move(threadMessageID.value()),
.threadUserID = std::move(threadUserID.value()),
.threadUserLogin = std::move(threadUserLogin.value()),
.threadUserName = std::move(threadUserName.value()),
};
}
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Event must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id");
if (jvbroadcasterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserID{
"Missing required key broadcaster_user_id"};
return boost::system::error_code{129,
error_missing_field_broadcasterUserID};
}
auto broadcasterUserID =
boost::json::try_value_to<std::string>(*jvbroadcasterUserID);
if (broadcasterUserID.has_error())
{
return broadcasterUserID.error();
}
const auto *jvbroadcasterUserLogin =
root.if_contains("broadcaster_user_login");
if (jvbroadcasterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserLogin{
"Missing required key broadcaster_user_login"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserLogin};
}
auto broadcasterUserLogin =
boost::json::try_value_to<std::string>(*jvbroadcasterUserLogin);
if (broadcasterUserLogin.has_error())
{
return broadcasterUserLogin.error();
}
const auto *jvbroadcasterUserName =
root.if_contains("broadcaster_user_name");
if (jvbroadcasterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserName{
"Missing required key broadcaster_user_name"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserName};
}
auto broadcasterUserName =
boost::json::try_value_to<std::string>(*jvbroadcasterUserName);
if (broadcasterUserName.has_error())
{
return broadcasterUserName.error();
}
const auto *jvchatterUserID = root.if_contains("chatter_user_id");
if (jvchatterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_chatterUserID{
"Missing required key chatter_user_id"};
return boost::system::error_code{129,
error_missing_field_chatterUserID};
}
auto chatterUserID =
boost::json::try_value_to<std::string>(*jvchatterUserID);
if (chatterUserID.has_error())
{
return chatterUserID.error();
}
const auto *jvchatterUserLogin = root.if_contains("chatter_user_login");
if (jvchatterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_chatterUserLogin{
"Missing required key chatter_user_login"};
return boost::system::error_code{129,
error_missing_field_chatterUserLogin};
}
auto chatterUserLogin =
boost::json::try_value_to<std::string>(*jvchatterUserLogin);
if (chatterUserLogin.has_error())
{
return chatterUserLogin.error();
}
const auto *jvchatterUserName = root.if_contains("chatter_user_name");
if (jvchatterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_chatterUserName{
"Missing required key chatter_user_name"};
return boost::system::error_code{129,
error_missing_field_chatterUserName};
}
auto chatterUserName =
boost::json::try_value_to<std::string>(*jvchatterUserName);
if (chatterUserName.has_error())
{
return chatterUserName.error();
}
const auto *jvcolor = root.if_contains("color");
if (jvcolor == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_color{
"Missing required key color"};
return boost::system::error_code{129, error_missing_field_color};
}
auto color = boost::json::try_value_to<std::string>(*jvcolor);
if (color.has_error())
{
return color.error();
}
const auto *jvbadges = root.if_contains("badges");
if (jvbadges == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_badges{
"Missing required key badges"};
return boost::system::error_code{129, error_missing_field_badges};
}
const auto badges =
boost::json::try_value_to<std::vector<Badge>>(*jvbadges);
if (badges.has_error())
{
return badges.error();
}
const auto *jvmessageID = root.if_contains("message_id");
if (jvmessageID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_messageID{"Missing required key message_id"};
return boost::system::error_code{129, error_missing_field_messageID};
}
auto messageID = boost::json::try_value_to<std::string>(*jvmessageID);
if (messageID.has_error())
{
return messageID.error();
}
const auto *jvmessageType = root.if_contains("message_type");
if (jvmessageType == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_messageType{
"Missing required key message_type"};
return boost::system::error_code{129, error_missing_field_messageType};
}
auto messageType = boost::json::try_value_to<std::string>(*jvmessageType);
if (messageType.has_error())
{
return messageType.error();
}
const auto *jvmessage = root.if_contains("message");
if (jvmessage == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_message{"Missing required key message"};
return boost::system::error_code{129, error_missing_field_message};
}
auto message = boost::json::try_value_to<Message>(*jvmessage);
if (message.has_error())
{
return message.error();
}
std::optional<Cheer> cheer = std::nullopt;
const auto *jvcheer = root.if_contains("cheer");
if (jvcheer != nullptr && !jvcheer->is_null())
{
auto tcheer = boost::json::try_value_to<Cheer>(*jvcheer);
if (tcheer.has_error())
{
return tcheer.error();
}
cheer = std::move(tcheer.value());
}
std::optional<Reply> reply = std::nullopt;
const auto *jvreply = root.if_contains("reply");
if (jvreply != nullptr && !jvreply->is_null())
{
auto treply = boost::json::try_value_to<Reply>(*jvreply);
if (treply.has_error())
{
return treply.error();
}
reply = std::move(treply.value());
}
std::optional<std::string> channelPointsCustomRewardID = std::nullopt;
const auto *jvchannelPointsCustomRewardID =
root.if_contains("channel_points_custom_reward_id");
if (jvchannelPointsCustomRewardID != nullptr &&
!jvchannelPointsCustomRewardID->is_null())
{
auto tchannelPointsCustomRewardID =
boost::json::try_value_to<std::string>(
*jvchannelPointsCustomRewardID);
if (tchannelPointsCustomRewardID.has_error())
{
return tchannelPointsCustomRewardID.error();
}
channelPointsCustomRewardID =
std::move(tchannelPointsCustomRewardID.value());
}
return Event{
.broadcasterUserID = std::move(broadcasterUserID.value()),
.broadcasterUserLogin = std::move(broadcasterUserLogin.value()),
.broadcasterUserName = std::move(broadcasterUserName.value()),
.chatterUserID = std::move(chatterUserID.value()),
.chatterUserLogin = std::move(chatterUserLogin.value()),
.chatterUserName = std::move(chatterUserName.value()),
.color = std::move(color.value()),
.badges = badges.value(),
.messageID = std::move(messageID.value()),
.messageType = std::move(messageType.value()),
.message = std::move(message.value()),
.cheer = std::move(cheer),
.reply = std::move(reply),
.channelPointsCustomRewardID = std::move(channelPointsCustomRewardID),
};
}
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsubscription = root.if_contains("subscription");
if (jvsubscription == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_subscription{
"Missing required key subscription"};
return boost::system::error_code{129, error_missing_field_subscription};
}
auto subscription =
boost::json::try_value_to<subscription::Subscription>(*jvsubscription);
if (subscription.has_error())
{
return subscription.error();
}
const auto *jvevent = root.if_contains("event");
if (jvevent == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_event{
"Missing required key event"};
return boost::system::error_code{129, error_missing_field_event};
}
auto event = boost::json::try_value_to<Event>(*jvevent);
if (event.has_error())
{
return event.error();
}
return Payload{
.subscription = std::move(subscription.value()),
.event = std::move(event.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::channel_chat_message::v1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
#include "twitch-eventsub-ws/payloads/channel-update-v1.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::channel_update::v1 {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Event must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id");
if (jvbroadcasterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserID{
"Missing required key broadcaster_user_id"};
return boost::system::error_code{129,
error_missing_field_broadcasterUserID};
}
auto broadcasterUserID =
boost::json::try_value_to<std::string>(*jvbroadcasterUserID);
if (broadcasterUserID.has_error())
{
return broadcasterUserID.error();
}
const auto *jvbroadcasterUserLogin =
root.if_contains("broadcaster_user_login");
if (jvbroadcasterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserLogin{
"Missing required key broadcaster_user_login"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserLogin};
}
auto broadcasterUserLogin =
boost::json::try_value_to<std::string>(*jvbroadcasterUserLogin);
if (broadcasterUserLogin.has_error())
{
return broadcasterUserLogin.error();
}
const auto *jvbroadcasterUserName =
root.if_contains("broadcaster_user_name");
if (jvbroadcasterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserName{
"Missing required key broadcaster_user_name"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserName};
}
auto broadcasterUserName =
boost::json::try_value_to<std::string>(*jvbroadcasterUserName);
if (broadcasterUserName.has_error())
{
return broadcasterUserName.error();
}
const auto *jvtitle = root.if_contains("title");
if (jvtitle == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_title{
"Missing required key title"};
return boost::system::error_code{129, error_missing_field_title};
}
auto title = boost::json::try_value_to<std::string>(*jvtitle);
if (title.has_error())
{
return title.error();
}
const auto *jvlanguage = root.if_contains("language");
if (jvlanguage == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_language{"Missing required key language"};
return boost::system::error_code{129, error_missing_field_language};
}
auto language = boost::json::try_value_to<std::string>(*jvlanguage);
if (language.has_error())
{
return language.error();
}
const auto *jvcategoryID = root.if_contains("category_id");
if (jvcategoryID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_categoryID{"Missing required key category_id"};
return boost::system::error_code{129, error_missing_field_categoryID};
}
auto categoryID = boost::json::try_value_to<std::string>(*jvcategoryID);
if (categoryID.has_error())
{
return categoryID.error();
}
const auto *jvcategoryName = root.if_contains("category_name");
if (jvcategoryName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_categoryName{
"Missing required key category_name"};
return boost::system::error_code{129, error_missing_field_categoryName};
}
auto categoryName = boost::json::try_value_to<std::string>(*jvcategoryName);
if (categoryName.has_error())
{
return categoryName.error();
}
const auto *jvisMature = root.if_contains("is_mature");
if (jvisMature == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_isMature{"Missing required key is_mature"};
return boost::system::error_code{129, error_missing_field_isMature};
}
auto isMature = boost::json::try_value_to<bool>(*jvisMature);
if (isMature.has_error())
{
return isMature.error();
}
return Event{
.broadcasterUserID = std::move(broadcasterUserID.value()),
.broadcasterUserLogin = std::move(broadcasterUserLogin.value()),
.broadcasterUserName = std::move(broadcasterUserName.value()),
.title = std::move(title.value()),
.language = std::move(language.value()),
.categoryID = std::move(categoryID.value()),
.categoryName = std::move(categoryName.value()),
.isMature = std::move(isMature.value()),
};
}
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsubscription = root.if_contains("subscription");
if (jvsubscription == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_subscription{
"Missing required key subscription"};
return boost::system::error_code{129, error_missing_field_subscription};
}
auto subscription =
boost::json::try_value_to<subscription::Subscription>(*jvsubscription);
if (subscription.has_error())
{
return subscription.error();
}
const auto *jvevent = root.if_contains("event");
if (jvevent == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_event{
"Missing required key event"};
return boost::system::error_code{129, error_missing_field_event};
}
auto event = boost::json::try_value_to<Event>(*jvevent);
if (event.has_error())
{
return event.error();
}
return Payload{
.subscription = std::move(subscription.value()),
.event = std::move(event.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::channel_update::v1
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
subscription_name="$1"
subscription_version="$2"
usage() {
>&2 echo "Usage: $0 <name> <version> (e.g. $0 channel.ban v1)"
exit 1
}
if [ -z "$subscription_name" ]; then
>&2 echo "Missing subscription name"
usage
fi
if [ -z "$subscription_version" ]; then
>&2 echo "Missing subscription version"
usage
fi
# Clean up subscription name
dashed_subscription_name="$(echo "$subscription_name" | sed 's/\./-/g')"
underscored_subscription_name="$(echo "$subscription_name" | sed 's/\./_/g')"
echo "Subscription name: $subscription_name ($dashed_subscription_name)"
echo "Subscription version: $subscription_version"
header_file_name="${dashed_subscription_name}-${subscription_version}.hpp"
source_file_name="${dashed_subscription_name}-${subscription_version}.cpp"
# Write the header file
cat > "$SCRIPT_DIR/../../include/twitch-eventsub-ws/payloads/$header_file_name" << EOF
#pragma once
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include <boost/json.hpp>
#include <string>
namespace chatterino::eventsub::lib::payload::$underscored_subscription_name::$subscription_version {
/// json_transform=snake_case
struct Event {
// TODO: Fill in your subscription-specific event here
};
struct Payload {
const subscription::Subscription subscription;
const Event event;
};
// DESERIALIZATION DEFINITION START
// DESERIALIZATION DEFINITION END
} // namespace chatterino::eventsub::lib::payload::$underscored_subscription_name::$subscription_version
EOF
# Write the source file
cat > "$SCRIPT_DIR/$source_file_name" << EOF
#include "twitch-eventsub-ws/payloads/$header_file_name"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::$underscored_subscription_name::$subscription_version {
// DESERIALIZATION IMPLEMENTATION START
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::$underscored_subscription_name::$subscription_version
EOF
echo "Steps that are left for you:"
echo "1. Add a virtual method to the Listener class in include/twitch-eventsub-ws/listener.hpp"
echo "2. Add a handler for this subscription in src/session.cpp's NOTIFICATION_HANDLERS"
echo "3. Add payloads/${source_file_name} to src/CMakeLists.txt"
@@ -0,0 +1,57 @@
#include "twitch-eventsub-ws/payloads/session-welcome.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::session_welcome {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &outerRoot = jvRoot.get_object();
const auto *jvInnerRoot = outerRoot.if_contains("session");
if (jvInnerRoot == nullptr)
{
static const error::ApplicationErrorCategory errorMissing{
"Payload's key session is missing"};
return boost::system::error_code{129, errorMissing};
}
if (!jvInnerRoot->is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload's session must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvInnerRoot->get_object();
const auto *jvid = root.if_contains("id");
if (jvid == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_id{
"Missing required key id"};
return boost::system::error_code{129, error_missing_field_id};
}
auto id = boost::json::try_value_to<std::string>(*jvid);
if (id.has_error())
{
return id.error();
}
return Payload{
.id = std::move(id.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::session_welcome
@@ -0,0 +1,134 @@
#include "twitch-eventsub-ws/payloads/stream-offline-v1.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::stream_offline::v1 {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Event must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id");
if (jvbroadcasterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserID{
"Missing required key broadcaster_user_id"};
return boost::system::error_code{129,
error_missing_field_broadcasterUserID};
}
auto broadcasterUserID =
boost::json::try_value_to<std::string>(*jvbroadcasterUserID);
if (broadcasterUserID.has_error())
{
return broadcasterUserID.error();
}
const auto *jvbroadcasterUserLogin =
root.if_contains("broadcaster_user_login");
if (jvbroadcasterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserLogin{
"Missing required key broadcaster_user_login"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserLogin};
}
auto broadcasterUserLogin =
boost::json::try_value_to<std::string>(*jvbroadcasterUserLogin);
if (broadcasterUserLogin.has_error())
{
return broadcasterUserLogin.error();
}
const auto *jvbroadcasterUserName =
root.if_contains("broadcaster_user_name");
if (jvbroadcasterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserName{
"Missing required key broadcaster_user_name"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserName};
}
auto broadcasterUserName =
boost::json::try_value_to<std::string>(*jvbroadcasterUserName);
if (broadcasterUserName.has_error())
{
return broadcasterUserName.error();
}
return Event{
.broadcasterUserID = std::move(broadcasterUserID.value()),
.broadcasterUserLogin = std::move(broadcasterUserLogin.value()),
.broadcasterUserName = std::move(broadcasterUserName.value()),
};
}
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsubscription = root.if_contains("subscription");
if (jvsubscription == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_subscription{
"Missing required key subscription"};
return boost::system::error_code{129, error_missing_field_subscription};
}
auto subscription =
boost::json::try_value_to<subscription::Subscription>(*jvsubscription);
if (subscription.has_error())
{
return subscription.error();
}
const auto *jvevent = root.if_contains("event");
if (jvevent == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_event{
"Missing required key event"};
return boost::system::error_code{129, error_missing_field_event};
}
auto event = boost::json::try_value_to<Event>(*jvevent);
if (event.has_error())
{
return event.error();
}
return Payload{
.subscription = std::move(subscription.value()),
.event = std::move(event.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::stream_offline::v1
@@ -0,0 +1,182 @@
#include "twitch-eventsub-ws/payloads/stream-online-v1.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::stream_online::v1 {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Event, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Event>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Event must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvid = root.if_contains("id");
if (jvid == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_id{
"Missing required key id"};
return boost::system::error_code{129, error_missing_field_id};
}
auto id = boost::json::try_value_to<std::string>(*jvid);
if (id.has_error())
{
return id.error();
}
const auto *jvbroadcasterUserID = root.if_contains("broadcaster_user_id");
if (jvbroadcasterUserID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserID{
"Missing required key broadcaster_user_id"};
return boost::system::error_code{129,
error_missing_field_broadcasterUserID};
}
auto broadcasterUserID =
boost::json::try_value_to<std::string>(*jvbroadcasterUserID);
if (broadcasterUserID.has_error())
{
return broadcasterUserID.error();
}
const auto *jvbroadcasterUserLogin =
root.if_contains("broadcaster_user_login");
if (jvbroadcasterUserLogin == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserLogin{
"Missing required key broadcaster_user_login"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserLogin};
}
auto broadcasterUserLogin =
boost::json::try_value_to<std::string>(*jvbroadcasterUserLogin);
if (broadcasterUserLogin.has_error())
{
return broadcasterUserLogin.error();
}
const auto *jvbroadcasterUserName =
root.if_contains("broadcaster_user_name");
if (jvbroadcasterUserName == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_broadcasterUserName{
"Missing required key broadcaster_user_name"};
return boost::system::error_code{
129, error_missing_field_broadcasterUserName};
}
auto broadcasterUserName =
boost::json::try_value_to<std::string>(*jvbroadcasterUserName);
if (broadcasterUserName.has_error())
{
return broadcasterUserName.error();
}
const auto *jvtype = root.if_contains("type");
if (jvtype == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_type{
"Missing required key type"};
return boost::system::error_code{129, error_missing_field_type};
}
auto type = boost::json::try_value_to<std::string>(*jvtype);
if (type.has_error())
{
return type.error();
}
const auto *jvstartedAt = root.if_contains("started_at");
if (jvstartedAt == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_startedAt{"Missing required key started_at"};
return boost::system::error_code{129, error_missing_field_startedAt};
}
auto startedAt = boost::json::try_value_to<std::string>(*jvstartedAt);
if (startedAt.has_error())
{
return startedAt.error();
}
return Event{
.id = std::move(id.value()),
.broadcasterUserID = std::move(broadcasterUserID.value()),
.broadcasterUserLogin = std::move(broadcasterUserLogin.value()),
.broadcasterUserName = std::move(broadcasterUserName.value()),
.type = std::move(type.value()),
.startedAt = std::move(startedAt.value()),
};
}
boost::json::result_for<Payload, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Payload>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Payload must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvsubscription = root.if_contains("subscription");
if (jvsubscription == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_subscription{
"Missing required key subscription"};
return boost::system::error_code{129, error_missing_field_subscription};
}
auto subscription =
boost::json::try_value_to<subscription::Subscription>(*jvsubscription);
if (subscription.has_error())
{
return subscription.error();
}
const auto *jvevent = root.if_contains("event");
if (jvevent == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_event{
"Missing required key event"};
return boost::system::error_code{129, error_missing_field_event};
}
auto event = boost::json::try_value_to<Event>(*jvevent);
if (event.has_error())
{
return event.error();
}
return Payload{
.subscription = std::move(subscription.value()),
.event = std::move(event.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::stream_online::v1
@@ -0,0 +1,186 @@
#include "twitch-eventsub-ws/payloads/subscription.hpp"
#include "twitch-eventsub-ws/errors.hpp"
#include <boost/json.hpp>
namespace chatterino::eventsub::lib::payload::subscription {
// DESERIALIZATION IMPLEMENTATION START
boost::json::result_for<Transport, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Transport>, const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Transport must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvmethod = root.if_contains("method");
if (jvmethod == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_method{
"Missing required key method"};
return boost::system::error_code{129, error_missing_field_method};
}
auto method = boost::json::try_value_to<std::string>(*jvmethod);
if (method.has_error())
{
return method.error();
}
const auto *jvsessionID = root.if_contains("session_id");
if (jvsessionID == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_sessionID{"Missing required key session_id"};
return boost::system::error_code{129, error_missing_field_sessionID};
}
auto sessionID = boost::json::try_value_to<std::string>(*jvsessionID);
if (sessionID.has_error())
{
return sessionID.error();
}
return Transport{
.method = std::move(method.value()),
.sessionID = std::move(sessionID.value()),
};
}
boost::json::result_for<Subscription, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<Subscription>,
const boost::json::value &jvRoot)
{
if (!jvRoot.is_object())
{
static const error::ApplicationErrorCategory errorMustBeObject{
"Subscription must be an object"};
return boost::system::error_code{129, errorMustBeObject};
}
const auto &root = jvRoot.get_object();
const auto *jvid = root.if_contains("id");
if (jvid == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_id{
"Missing required key id"};
return boost::system::error_code{129, error_missing_field_id};
}
auto id = boost::json::try_value_to<std::string>(*jvid);
if (id.has_error())
{
return id.error();
}
const auto *jvstatus = root.if_contains("status");
if (jvstatus == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_status{
"Missing required key status"};
return boost::system::error_code{129, error_missing_field_status};
}
auto status = boost::json::try_value_to<std::string>(*jvstatus);
if (status.has_error())
{
return status.error();
}
const auto *jvtype = root.if_contains("type");
if (jvtype == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_type{
"Missing required key type"};
return boost::system::error_code{129, error_missing_field_type};
}
auto type = boost::json::try_value_to<std::string>(*jvtype);
if (type.has_error())
{
return type.error();
}
const auto *jvversion = root.if_contains("version");
if (jvversion == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_version{"Missing required key version"};
return boost::system::error_code{129, error_missing_field_version};
}
auto version = boost::json::try_value_to<std::string>(*jvversion);
if (version.has_error())
{
return version.error();
}
const auto *jvtransport = root.if_contains("transport");
if (jvtransport == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_transport{"Missing required key transport"};
return boost::system::error_code{129, error_missing_field_transport};
}
auto transport = boost::json::try_value_to<Transport>(*jvtransport);
if (transport.has_error())
{
return transport.error();
}
const auto *jvcreatedAt = root.if_contains("created_at");
if (jvcreatedAt == nullptr)
{
static const error::ApplicationErrorCategory
error_missing_field_createdAt{"Missing required key created_at"};
return boost::system::error_code{129, error_missing_field_createdAt};
}
auto createdAt = boost::json::try_value_to<std::string>(*jvcreatedAt);
if (createdAt.has_error())
{
return createdAt.error();
}
const auto *jvcost = root.if_contains("cost");
if (jvcost == nullptr)
{
static const error::ApplicationErrorCategory error_missing_field_cost{
"Missing required key cost"};
return boost::system::error_code{129, error_missing_field_cost};
}
auto cost = boost::json::try_value_to<int>(*jvcost);
if (cost.has_error())
{
return cost.error();
}
return Subscription{
.id = std::move(id.value()),
.status = std::move(status.value()),
.type = std::move(type.value()),
.version = std::move(version.value()),
.transport = std::move(transport.value()),
.createdAt = std::move(createdAt.value()),
.cost = std::move(cost.value()),
};
}
// DESERIALIZATION IMPLEMENTATION END
} // namespace chatterino::eventsub::lib::payload::subscription
+438
View File
@@ -0,0 +1,438 @@
#include "twitch-eventsub-ws/session.hpp"
#include "twitch-eventsub-ws/listener.hpp"
#include "twitch-eventsub-ws/messages/metadata.hpp"
#include "twitch-eventsub-ws/payloads/channel-ban-v1.hpp"
#include "twitch-eventsub-ws/payloads/session-welcome.hpp"
#include <boost/asio.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/ssl.hpp>
#include <boost/container_hash/hash.hpp>
#include <boost/json.hpp>
#include <array>
#include <chrono>
#include <iostream>
#include <memory>
#include <sstream>
#include <unordered_map>
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace chatterino::eventsub::lib {
// Subscription Type + Subscription Version
using EventSubSubscription = std::pair<std::string, std::string>;
using NotificationHandlers = std::unordered_map<
EventSubSubscription,
std::function<void(messages::Metadata, boost::json::value,
std::unique_ptr<Listener> &)>,
boost::hash<EventSubSubscription>>;
using MessageHandlers = std::unordered_map<
std::string, std::function<void(messages::Metadata, boost::json::value,
std::unique_ptr<Listener> &,
const NotificationHandlers &)>>;
namespace {
// Report a failure
void fail(beast::error_code ec, char const *what)
{
std::cerr << what << ": " << ec.message() << "\n";
}
template <class T>
std::optional<T> parsePayload(const boost::json::value &jv)
{
auto result = boost::json::try_value_to<T>(jv);
if (!result.has_value())
{
fail(result.error(), "parsing payload");
return std::nullopt;
}
return std::move(result.value());
}
// Subscription types
const NotificationHandlers NOTIFICATION_HANDLERS{
{
{"channel.ban", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::channel_ban::v1::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onChannelBan(metadata, *oPayload);
},
},
{
{"stream.online", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::stream_online::v1::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onStreamOnline(metadata, *oPayload);
},
},
{
{"stream.offline", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::stream_offline::v1::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onStreamOffline(metadata, *oPayload);
},
},
{
{"channel.chat.notification", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload = parsePayload<
payload::channel_chat_notification::v1::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onChannelChatNotification(metadata, *oPayload);
},
},
{
{"channel.update", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::channel_update::v1::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onChannelUpdate(metadata, *oPayload);
},
},
{
{"channel.chat.message", "1"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::channel_chat_message::v1::Payload>(
jv);
if (!oPayload)
{
return;
}
listener->onChannelChatMessage(metadata, *oPayload);
},
},
{
{"channel.moderate", "2"},
[](const auto &metadata, const auto &jv, auto &listener) {
auto oPayload =
parsePayload<payload::channel_moderate::v2::Payload>(jv);
if (!oPayload)
{
return;
}
listener->onChannelModerate(metadata, std::move(*oPayload));
},
},
// Add your new subscription types above this line
};
const MessageHandlers MESSAGE_HANDLERS{
{
"session_welcome",
[](const auto &metadata, const auto &jv, auto &listener,
const auto & /*notificationHandlers*/) {
auto oPayload =
parsePayload<payload::session_welcome::Payload>(jv);
if (!oPayload)
{
// TODO: error handling
return;
}
const auto &payload = *oPayload;
listener->onSessionWelcome(metadata, payload);
},
},
{
"session_keepalive",
[](const auto &metadata, const auto &jv, auto &listener,
const auto &notificationHandlers) {
// TODO: should we do something here?
},
},
{
"notification",
[](const auto &metadata, const auto &jv, auto &listener,
const auto &notificationHandlers) {
listener->onNotification(metadata, jv);
if (!metadata.subscriptionType || !metadata.subscriptionVersion)
{
// TODO: error handling
return;
}
auto it =
notificationHandlers.find({*metadata.subscriptionType,
*metadata.subscriptionVersion});
if (it == notificationHandlers.end())
{
// TODO: error handling
return;
}
it->second(metadata, jv, listener);
},
},
};
} // namespace
boost::system::error_code handleMessage(std::unique_ptr<Listener> &listener,
const beast::flat_buffer &buffer)
{
boost::system::error_code parseError;
auto jv =
boost::json::parse(beast::buffers_to_string(buffer.data()), parseError);
if (parseError)
{
// TODO: wrap error?
return parseError;
}
const auto *jvObject = jv.if_object();
if (jvObject == nullptr)
{
static const error::ApplicationErrorCategory errorRootMustBeObject{
"Payload root must be an object"};
return boost::system::error_code{129, errorRootMustBeObject};
}
const auto *metadataV = jvObject->if_contains("metadata");
if (metadataV == nullptr)
{
static const error::ApplicationErrorCategory
errorRootMustContainMetadata{
"Payload root must contain a metadata field"};
return boost::system::error_code{129, errorRootMustContainMetadata};
}
auto metadataResult =
boost::json::try_value_to<messages::Metadata>(*metadataV);
if (metadataResult.has_error())
{
// TODO: wrap error?
return metadataResult.error();
}
const auto &metadata = metadataResult.value();
auto handler = MESSAGE_HANDLERS.find(metadata.messageType);
if (handler == MESSAGE_HANDLERS.end())
{
std::stringstream ss;
ss << "No message handler found for message type: ";
ss << metadata.messageType;
error::ApplicationErrorCategory errorNoMessageHandlerForMessageType{
ss.str()};
return boost::system::error_code{129,
errorNoMessageHandlerForMessageType};
}
const auto *payloadV = jvObject->if_contains("payload");
if (payloadV == nullptr)
{
static const error::ApplicationErrorCategory
errorRootMustContainPayload{
"Payload root must contain a payload field"};
return boost::system::error_code{129, errorRootMustContainPayload};
}
handler->second(metadata, *payloadV, listener, NOTIFICATION_HANDLERS);
return {};
}
// Resolver and socket require an io_context
Session::Session(boost::asio::io_context &ioc, boost::asio::ssl::context &ctx,
std::unique_ptr<Listener> listener)
: resolver(boost::asio::make_strand(ioc))
, ws(boost::asio::make_strand(ioc), ctx)
, listener(std::move(listener))
{
}
// Start the asynchronous operation
void Session::run(std::string _host, std::string _port, std::string _path,
std::string _userAgent)
{
// Save these for later
this->host = std::move(_host);
this->port = std::move(_port);
this->path = std::move(_path);
this->userAgent = std::move(_userAgent);
// Look up the domain name
this->resolver.async_resolve(
this->host, this->port,
beast::bind_front_handler(&Session::onResolve, shared_from_this()));
}
void Session::close()
{
boost::beast::websocket::close_reason closeReason("Shutting down");
// TODO: Test this with a misbehaving eventsub server that doesn't respond to our close
this->ws.async_close(
closeReason,
beast::bind_front_handler(&Session::onClose, shared_from_this()));
}
Listener *Session::getListener()
{
return this->listener.get();
}
void Session::onResolve(beast::error_code ec,
boost::asio::ip::tcp::resolver::results_type results)
{
if (ec)
{
return fail(ec, "resolve");
}
// Set a timeout on the operation
beast::get_lowest_layer(this->ws).expires_after(std::chrono::seconds(30));
// Make the connection on the IP address we get from a lookup
beast::get_lowest_layer(this->ws).async_connect(
results,
beast::bind_front_handler(&Session::onConnect, shared_from_this()));
}
void Session::onConnect(
beast::error_code ec,
boost::asio::ip::tcp::resolver::results_type::endpoint_type ep)
{
if (ec)
{
return fail(ec, "connect");
}
// Set a timeout on the operation
beast::get_lowest_layer(this->ws).expires_after(std::chrono::seconds(30));
// Set SNI Hostname (many hosts need this to handshake successfully)
if (!SSL_set_tlsext_host_name(this->ws.next_layer().native_handle(),
this->host.c_str()))
{
ec = beast::error_code(static_cast<int>(::ERR_get_error()),
boost::asio::error::get_ssl_category());
return fail(ec, "connect");
}
// Update the host_ string. This will provide the value of the
// Host HTTP header during the WebSocket handshake.
// See https://tools.ietf.org/html/rfc7230#section-5.4
host += ':' + std::to_string(ep.port());
// Perform the SSL handshake
this->ws.next_layer().async_handshake(
boost::asio::ssl::stream_base::client,
beast::bind_front_handler(&Session::onSSLHandshake,
shared_from_this()));
}
void Session::onSSLHandshake(beast::error_code ec)
{
if (ec)
{
return fail(ec, "ssl_handshake");
}
// Turn off the timeout on the tcp_stream, because
// the websocket stream has its own timeout system.
beast::get_lowest_layer(this->ws).expires_never();
// Set suggested timeout settings for the websocket
this->ws.set_option(
websocket::stream_base::timeout::suggested(beast::role_type::client));
// Set a decorator to change the User-Agent of the handshake
this->ws.set_option(websocket::stream_base::decorator(
[userAgent{this->userAgent}](websocket::request_type &req) {
req.set(http::field::user_agent, userAgent);
}));
// Perform the websocket handshake
this->ws.async_handshake(
this->host, this->path,
beast::bind_front_handler(&Session::onHandshake, shared_from_this()));
}
void Session::onHandshake(beast::error_code ec)
{
if (ec)
{
return fail(ec, "handshake");
}
this->ws.async_read(buffer, beast::bind_front_handler(&Session::onRead,
shared_from_this()));
}
void Session::onRead(beast::error_code ec, std::size_t bytes_transferred)
{
boost::ignore_unused(bytes_transferred);
if (ec)
{
return fail(ec, "read");
}
auto messageError = handleMessage(this->listener, this->buffer);
if (messageError)
{
return fail(messageError, "handleMessage");
}
this->buffer.clear();
this->ws.async_read(buffer, beast::bind_front_handler(&Session::onRead,
shared_from_this()));
}
/**
this->ws_.async_close(
websocket::close_code::normal,
beast::bind_front_handler(&Session::onClose, shared_from_this()));
*/
void Session::onClose(beast::error_code ec)
{
if (ec)
{
return fail(ec, "close");
}
// If we get here then the connection is closed gracefully
// The make_printable() function helps print a ConstBufferSequence
std::cout << beast::make_printable(buffer.data()) << std::endl;
}
} // namespace chatterino::eventsub::lib
+23
View File
@@ -0,0 +1,23 @@
#include "twitch-eventsub-ws/string.hpp"
#include <boost/json.hpp>
#include <QString>
#include <string>
namespace chatterino::eventsub::lib {
boost::json::result_for<String, boost::json::value>::type tag_invoke(
boost::json::try_value_to_tag<String> /*tag*/,
const boost::json::value &jvRoot)
{
auto v = boost::json::try_value_to<std::string>(jvRoot);
if (v.has_error())
{
return v.error();
}
return String(std::move(v.value()));
}
} // namespace chatterino::eventsub::lib
+7
View File
@@ -271,6 +271,13 @@ public:
return nullptr; return nullptr;
} }
eventsub::IController *getEventSub() override
{
assert(false && "EmptyApplication::getEventSub was called without "
"being initialized");
return nullptr;
}
QTemporaryDir settingsDir; QTemporaryDir settingsDir;
Paths paths_; Paths paths_;
Args args_; Args args_;
+9
View File
@@ -427,6 +427,15 @@ public:
FailureCallback<QString> failureCallback), FailureCallback<QString> failureCallback),
(override)); (override));
MOCK_METHOD(void, createEventSubSubscription,
(const eventsub::SubscriptionRequest &request,
const QString &sessionID,
ResultCallback<HelixCreateEventSubSubscriptionResponse>
successCallback,
(FailureCallback<HelixCreateEventSubSubscriptionError, QString>
failureCallback)),
(override));
MOCK_METHOD(void, update, (QString clientId, QString oauthToken), MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
(override)); (override));
@@ -0,0 +1,30 @@
The MIT License (MIT)
Copyright (c) 2015, 2016, 2017 Howard Hinnant
Copyright (c) 2016 Adrian Colomitchi
Copyright (c) 2017 Florian Dang
Copyright (c) 2017 Paul Thompson
Copyright (c) 2018, 2019 Tomasz Kamiński
Copyright (c) 2019 Jiangang Zhuang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Our apologies. When the previous paragraph was written, lowercase had not yet
been invented (that would involve another several millennia of evolution).
We did not mean to shout.
+22
View File
@@ -18,6 +18,7 @@
#include "providers/pronouns/Pronouns.hpp" #include "providers/pronouns/Pronouns.hpp"
#include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvAPI.hpp"
#include "providers/seventv/SeventvEmotes.hpp" #include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/eventsub/Controller.hpp"
#include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchBadges.hpp"
#include "singletons/ImageUploader.hpp" #include "singletons/ImageUploader.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS #ifdef CHATTERINO_HAVE_PLUGINS
@@ -120,6 +121,18 @@ SeventvEventAPI *makeSeventvEventAPI(Settings &settings)
return nullptr; return nullptr;
} }
eventsub::IController *makeEventSubController(Settings &settings)
{
bool enabled = settings.enableExperimentalEventSub;
if (enabled)
{
return new eventsub::Controller();
}
return new eventsub::DummyController();
}
const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv"; const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv";
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -180,6 +193,7 @@ Application::Application(Settings &_settings, const Paths &paths,
, streamerMode(new StreamerMode) , streamerMode(new StreamerMode)
, twitchUsers(new TwitchUsers) , twitchUsers(new TwitchUsers)
, pronouns(new pronouns::Pronouns) , pronouns(new pronouns::Pronouns)
, eventSub(makeEventSubController(_settings))
#ifdef CHATTERINO_HAVE_PLUGINS #ifdef CHATTERINO_HAVE_PLUGINS
, plugins(new PluginController(paths)) , plugins(new PluginController(paths))
#endif #endif
@@ -576,6 +590,14 @@ pronouns::Pronouns *Application::getPronouns()
return this->pronouns.get(); return this->pronouns.get();
} }
eventsub::IController *Application::getEventSub()
{
assertInGuiThread();
assert(this->eventSub);
return this->eventSub.get();
}
void Application::save() void Application::save()
{ {
this->commands->save(); this->commands->save();

Some files were not shown because too many files have changed in this diff Show More