+
However, since this requires the tree to be complete
and parsed, it'll need to be done at a different stage and then
replaced.
@@ -129,13 +144,14 @@ def run(self):
content = self.arguments[0].strip()
node = attributetableplaceholder("")
modulename, name = self.parse_name(content)
+ node["python-doc"] = self.env.docname
node["python-module"] = modulename
node["python-class"] = name
- node["python-full-name"] = "%s.%s" % (modulename, name)
+ node["python-full-name"] = f"{modulename}.{name}"
return [node]
-def build_lookup_table(env):
+def build_lookup_table(env: BuildEnvironment) -> dict[str, list[str]]:
# Given an environment, load up a lookup table of
# full-class-name: objects
result = {}
@@ -148,7 +164,7 @@ def build_lookup_table(env):
"class",
}
- for (fullname, _, objtype, docname, _, _) in domain.get_objects():
+ for fullname, _, objtype, docname, _, _ in domain.get_objects():
if objtype in ignored:
continue
@@ -161,10 +177,13 @@ def build_lookup_table(env):
return result
-TableElement = namedtuple("TableElement", "fullname label badge")
+class TableElement(NamedTuple):
+ fullname: str
+ label: str
+ badge: attributetablebadge | None
-def process_attributetable(app, doctree, fromdocname):
+def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) -> None:
env = app.builder.env
lookup = build_lookup_table(env)
@@ -185,16 +204,16 @@ def process_attributetable(app, doctree, fromdocname):
node.replace_self([table])
-def get_class_results(lookup, modulename, name, fullname):
+def get_class_results(
+ lookup: dict[str, list[str]], modulename: str, name: str, fullname: str
+) -> dict[str, list[TableElement]]:
module = importlib.import_module(modulename)
cls = getattr(module, name)
- groups = OrderedDict(
- [
- (_("Attributes"), []),
- (_("Methods"), []),
- ]
- )
+ groups: dict[str, list[TableElement]] = {
+ _("Attributes"): [],
+ _("Methods"): [],
+ }
try:
members = lookup[fullname]
@@ -202,10 +221,14 @@ def get_class_results(lookup, modulename, name, fullname):
return groups
for attr in members:
- attrlookup = "%s.%s" % (fullname, attr)
+ if attr.startswith("__") and attr.endswith("__"):
+ continue
+
+ attrlookup = f"{fullname}.{attr}"
key = _("Attributes")
badge = None
label = attr
+ value = None
for base in cls.__mro__:
value = base.__dict__.get(attr)
@@ -214,21 +237,26 @@ def get_class_results(lookup, modulename, name, fullname):
if value is not None:
doc = value.__doc__ or ""
+
if inspect.iscoroutinefunction(value) or doc.startswith("|coro|"):
key = _("Methods")
badge = attributetablebadge("async", "async")
badge["badge-type"] = _("coroutine")
elif isinstance(value, classmethod):
key = _("Methods")
- label = "%s.%s" % (name, attr)
+ label = f"{name}.{attr}"
badge = attributetablebadge("cls", "cls")
badge["badge-type"] = _("classmethod")
elif inspect.isfunction(value):
- if doc.startswith(("A decorator", "A shortcut decorator")):
+ if doc.startswith(("A decorator", "A shortcut decorator", "|deco|")):
# finicky but surprisingly consistent
+ key = _("Methods")
badge = attributetablebadge("@", "@")
badge["badge-type"] = _("decorator")
+ elif inspect.isasyncgenfunction(value) or doc.startswith("|aiter|"):
key = _("Methods")
+ badge = attributetablebadge("async for", "async for")
+ badge["badge-type"] = _("async iterable")
else:
key = _("Methods")
badge = attributetablebadge("def", "def")
@@ -239,12 +267,12 @@ def get_class_results(lookup, modulename, name, fullname):
return groups
-def class_results_to_node(key, elements):
+def class_results_to_node(key: str, elements: Sequence[TableElement]) -> attributetablecolumn:
title = attributetabletitle(key, key)
ul = nodes.bullet_list("")
for element in elements:
ref = nodes.reference(
- "", "", internal=True, refuri="#" + element.fullname, anchorname="", *[nodes.Text(element.label)]
+ "", "", internal=True, refuri=f"#{element.fullname}", anchorname="", *[nodes.Text(element.label)]
)
para = addnodes.compact_paragraph("", "", ref)
if element.badge is not None:
@@ -255,7 +283,7 @@ def class_results_to_node(key, elements):
return attributetablecolumn("", title, ul)
-def setup(app):
+def setup(app: Sphinx):
app.add_directive("attributetable", PyAttributeTable)
app.add_node(attributetable, html=(visit_attributetable_node, depart_attributetable_node))
app.add_node(attributetablecolumn, html=(visit_attributetablecolumn_node, depart_attributetablecolumn_node))
@@ -264,3 +292,4 @@ def setup(app):
app.add_node(attributetable_item, html=(visit_attributetable_item_node, depart_attributetable_item_node))
app.add_node(attributetableplaceholder)
app.connect("doctree-resolved", process_attributetable)
+ return {"parallel_read_safe": True}
diff --git a/docs/_extensions/details.py b/docs/_extensions/details.py
new file mode 100644
index 00000000..b46a4419
--- /dev/null
+++ b/docs/_extensions/details.py
@@ -0,0 +1,62 @@
+from docutils import nodes
+from docutils.parsers.rst import Directive, directives
+from docutils.parsers.rst.roles import set_classes
+
+
+class details(nodes.General, nodes.Element):
+ pass
+
+
+class summary(nodes.General, nodes.Element):
+ pass
+
+
+def visit_details_node(self, node):
+ self.body.append(self.starttag(node, "details", CLASS=node.attributes.get("class", "")))
+
+
+def visit_summary_node(self, node):
+ self.body.append(self.starttag(node, "summary", CLASS=node.attributes.get("summary-class", "")))
+ self.body.append(node.rawsource)
+
+
+def depart_details_node(self, node):
+ self.body.append("\n")
+
+
+def depart_summary_node(self, node):
+ self.body.append("")
+
+
+class DetailsDirective(Directive):
+ final_argument_whitespace = True
+ optional_arguments = 1
+
+ option_spec = {
+ "class": directives.class_option,
+ "summary-class": directives.class_option,
+ }
+
+ has_content = True
+
+ def run(self):
+ set_classes(self.options)
+ self.assert_has_content()
+
+ text = "\n".join(self.content)
+ node = details(text, **self.options)
+
+ if self.arguments:
+ summary_node = summary(self.arguments[0], **self.options)
+ summary_node.source, summary_node.line = self.state_machine.get_source_and_line(self.lineno)
+ node += summary_node
+
+ self.state.nested_parse(self.content, self.content_offset, node)
+ return [node]
+
+
+def setup(app):
+ app.add_node(details, html=(visit_details_node, depart_details_node))
+ app.add_node(summary, html=(visit_summary_node, depart_summary_node))
+ app.add_directive("details", DetailsDirective)
+ return {"parallel_read_safe": True}
diff --git a/docs/_extensions/exc_hierarchy.py b/docs/_extensions/exc_hierarchy.py
new file mode 100644
index 00000000..974de2df
--- /dev/null
+++ b/docs/_extensions/exc_hierarchy.py
@@ -0,0 +1,28 @@
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import states, directives
+from docutils.parsers.rst.roles import set_classes
+from docutils import nodes
+from sphinx.locale import _
+
+class exception_hierarchy(nodes.General, nodes.Element):
+ pass
+
+def visit_exception_hierarchy_node(self, node):
+ self.body.append(self.starttag(node, 'div', CLASS='exception-hierarchy-content'))
+
+def depart_exception_hierarchy_node(self, node):
+ self.body.append('\n')
+
+class ExceptionHierarchyDirective(Directive):
+ has_content = True
+
+ def run(self):
+ self.assert_has_content()
+ node = exception_hierarchy('\n'.join(self.content))
+ self.state.nested_parse(self.content, self.content_offset, node)
+ return [node]
+
+def setup(app):
+ app.add_node(exception_hierarchy, html=(visit_exception_hierarchy_node, depart_exception_hierarchy_node))
+ app.add_directive('exception_hierarchy', ExceptionHierarchyDirective)
+ return {'parallel_read_safe': True}
\ No newline at end of file
diff --git a/docs/_extensions/sig_prefix.py b/docs/_extensions/sig_prefix.py
new file mode 100644
index 00000000..78e56a67
--- /dev/null
+++ b/docs/_extensions/sig_prefix.py
@@ -0,0 +1,203 @@
+from __future__ import annotations
+
+import re
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from sphinx import addnodes
+from sphinx.application import Sphinx
+from sphinx.domains.python import PyFunction, PyMethod
+from sphinx.ext.autodoc import FunctionDocumenter, MethodDocumenter
+from sphinx.writers.html5 import HTML5Translator
+
+
+NAME_RE: re.Pattern[str] = re.compile(r"(?P[\w.]+\.)?(?P\w+)")
+PYTHON_DOC_STD: str = "https://docs.python.org/3/library/stdtypes.html"
+
+
+class hrnode(nodes.General, nodes.Element):
+ pass
+
+
+class usagetable(nodes.General, nodes.Element):
+ pass
+
+
+class aiter(nodes.General, nodes.Element):
+ pass
+
+
+def visit_usagetable_node(self: HTML5Translator, node: usagetable):
+ self.body.append(self.starttag(node, "div", CLASS="sig-usagetable"))
+
+
+def depart_usagetable_node(self: HTML5Translator, node: usagetable):
+ self.body.append("")
+
+
+def visit_aiterinfo_node(self: HTML5Translator, node: aiter):
+ dot = "." if node.get("python-class-name", False) else ""
+
+ self.body.append(self.starttag(node, "span", CLASS="pre"))
+
+ self.body.append("await ")
+ self.body.append(self.starttag(node, "span", CLASS="sig-name"))
+ self.body.append(f"{dot}{node['python-name']}(...)")
+
+ self.body.append(self.starttag(node, "span"))
+ self.body.append(" -> ")
+ self.body.append("")
+
+ list_ = f"{PYTHON_DOC_STD}#list"
+ self.body.append(self.starttag(node, "a", href=list_))
+ self.body.append("list")
+ self.body.append("")
+
+ self.body.append("[T]")
+
+ self.body.append(self.starttag(node, "br"))
+
+ self.body.append("async for item in ")
+ self.body.append(self.starttag(node, "span", CLASS="sig-name"))
+ self.body.append(f"{dot}{node['python-name']}(...)")
+ self.body.append("")
+ self.body.append(":")
+
+
+def depart_aiterinfo_node(self: HTML5Translator, node: aiter):
+ self.body.append("")
+
+
+def visit_hr_node(self: HTML5Translator, node: hrnode):
+ self.body.append(self.starttag(node, "hr"))
+
+
+def depart_hr_node(self: HTML5Translator, node: hrnode):
+ self.body.append("")
+
+
+def check_return(sig: str) -> bool:
+ if not sig:
+ return False
+
+ splat = sig.split("->")
+ ret = splat[-1]
+
+ return "HTTPAsyncIterator" in ret
+
+
+class AiterPyF(PyFunction):
+ option_spec = PyFunction.option_spec.copy()
+ option_spec.update({"aiter": directives.flag, "deco": directives.flag})
+
+ def parse_name_(self, content: str) -> tuple[str | None, str]:
+ match = NAME_RE.match(content)
+
+ if match is None:
+ raise RuntimeError(f"content {content} somehow doesn't match regex in {self.env.docname}.")
+
+ path, name = match.groups()
+
+ if path:
+ modulename = path.rstrip(".")
+ else:
+ modulename = self.env.temp_data.get("autodoc:module")
+ if not modulename:
+ modulename = self.env.ref_context.get("py:module")
+
+ return modulename, name
+
+ def get_signature_prefix(self, sig: str) -> list[nodes.Node]:
+ mname, name = self.parse_name_(sig)
+
+ if "aiter" in self.options:
+ node = aiter()
+ node["python-fullname"] = f"{mname}.{name}"
+ node["python-name"] = name
+ node["python-module"] = mname
+
+ parent = usagetable("", node)
+ return [parent, hrnode(), addnodes.desc_sig_keyword("", "async"), addnodes.desc_sig_space()]
+ elif "deco" in self.options:
+ return [addnodes.desc_sig_keyword("", "@"), addnodes.desc_sig_space()]
+
+ return super().get_signature_prefix(sig)
+
+
+class AiterPyM(PyMethod):
+ option_spec = PyMethod.option_spec.copy()
+ option_spec.update({"aiter": directives.flag, "deco": directives.flag})
+
+ def parse_name_(self, content: str) -> tuple[str, str]:
+ match = NAME_RE.match(content)
+
+ if match is None:
+ raise RuntimeError(f"content {content} somehow doesn't match regex in {self.env.docname}.")
+
+ cls, name = match.groups()
+ return cls, name
+
+ def get_signature_prefix(self, sig: str) -> list[nodes.Node]:
+ cname, name = self.parse_name_(sig)
+
+ if "aiter" in self.options:
+ node = aiter()
+ node["python-name"] = name
+ node["python-class-name"] = cname
+
+ parent = usagetable("", node)
+ return [parent, hrnode(), addnodes.desc_sig_keyword("", "async"), addnodes.desc_sig_space()]
+ elif "deco" in self.options:
+ return [addnodes.desc_sig_keyword("", "@"), addnodes.desc_sig_space()]
+
+ return super().get_signature_prefix(sig)
+
+
+class AiterFuncDocumenter(FunctionDocumenter):
+ objtype = "function"
+ priority = FunctionDocumenter.priority + 1
+
+ def add_directive_header(self, sig: str) -> None:
+ super().add_directive_header(sig)
+
+ sourcename = self.get_sourcename()
+ docs = self.object.__doc__ or ""
+
+ if docs.startswith("|aiter|") or check_return(sig):
+ self.add_line(" :aiter:", sourcename)
+ elif docs.startswith("|deco|"):
+ self.add_line(" :deco:", sourcename)
+
+
+class AiterMethDocumenter(MethodDocumenter):
+ objtype = "method"
+ priority = MethodDocumenter.priority + 1
+
+ def add_directive_header(self, sig: str) -> None:
+ super().add_directive_header(sig)
+
+ sourcename = self.get_sourcename()
+ obj = self.parent.__dict__.get(self.object_name, self.object)
+
+ docs = obj.__doc__ or ""
+ if docs.startswith("|aiter|") or check_return(sig):
+ self.add_line(" :aiter:", sourcename)
+ elif docs.startswith("|deco|"):
+ self.add_line(" :deco:", sourcename)
+
+
+def setup(app: Sphinx) -> dict[str, bool]:
+ app.setup_extension("sphinx.directives")
+ app.setup_extension("sphinx.ext.autodoc")
+
+ app.add_directive_to_domain("py", "function", AiterPyF, override=True)
+ app.add_directive_to_domain("py", "method", AiterPyM, override=True)
+
+ app.add_autodocumenter(AiterMethDocumenter, override=True)
+ app.add_autodocumenter(AiterFuncDocumenter, override=True)
+
+ app.add_node(aiter, html=(visit_aiterinfo_node, depart_aiterinfo_node))
+ app.add_node(usagetable, html=(visit_usagetable_node, depart_usagetable_node))
+ app.add_node(hrnode, html=(visit_hr_node, depart_hr_node))
+
+ return {"parallel_read_safe": True}
diff --git a/docs/_static/codeblocks.css b/docs/_static/codeblocks.css
new file mode 100644
index 00000000..49ac323c
--- /dev/null
+++ b/docs/_static/codeblocks.css
@@ -0,0 +1,120 @@
+.theme-dark {
+ pre > .c1 {
+ color: rgba(255, 255, 255, 0.4);
+ }
+ pre > .kn {
+ color: #e0a2f3;
+ }
+
+ pre > .k {
+ color: #e0a2f3;
+ }
+
+ pre > .kc {
+ color: #e0a2f3;
+ }
+
+ pre > .nb {
+ color: #96b7ff;
+ }
+
+ pre > .nn {
+ color: rgba(248, 248, 248, 0.9);
+ }
+ pre > .n {
+ color: rgba(248, 248, 248, 0.9);
+ }
+ pre > .p {
+ color: rgba(248, 248, 248, 0.9);
+ }
+ pre > .o {
+ color: rgba(248, 248, 248, 0.8);
+ }
+
+ pre > .ow {
+ color: #e0a2f3;
+ }
+
+ pre > .s2 {
+ color: #8abb89;
+ }
+
+ pre > .mi {
+ color: #fab011;
+ }
+
+ pre > .nd {
+ color: #fab011;
+ }
+
+ pre > .nf {
+ color: #97b2eb;
+ }
+
+ pre > .nc {
+ color: #ffc74f;
+ }
+
+ pre > .bp {
+ color: #97b2eb;
+ }
+}
+
+body:not(.theme-dark) > * {
+ pre > .c1 {
+ color: rgba(0, 0, 0, 0.5);
+ }
+
+ pre > .kn {
+ color: #a626a4;
+ }
+
+ pre > .k {
+ color: #a626a4;
+ }
+
+ pre > .kc {
+ color: #c086e7;
+ }
+
+ pre > .nn {
+ color: rgba(21, 21, 28, 0.9);
+ }
+ pre > .n {
+ color: rgba(21, 21, 28, 0.9);
+ }
+ pre > .p {
+ color: rgba(21, 21, 28, 0.9);
+ }
+ pre > .o {
+ color: rgba(21, 21, 28, 0.9);
+ }
+
+ pre > .ow {
+ color: #c086e7;
+ }
+
+ pre > .s2 {
+ color: #50a14f;
+ }
+
+ pre > .nb {
+ color: #986801;
+ }
+
+ pre > .mi {
+ color: #986801;
+ }
+
+ pre > .nf {
+ color: #4078f2;
+ }
+
+ pre > .nc {
+ color: #c18401;
+ }
+
+ pre > .bp {
+ color: #4078f2;
+ }
+}
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 00000000..6cce15f9
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,683 @@
+@import url("https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200..1000;1,200..1000&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&family=PT+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap");
+
+:root {
+ /* Dark Theme */
+ --color-primary--dark: rgba(255, 255, 255, 0.8);
+ --color-primary-dim--dark: rgba(255, 255, 255, 0.7);
+ --color-background--dark: #15151c;
+ --color-background-dim--dark: #1a1a2390;
+ --color-accent--dark: #dcb5f3;
+ --color-links--dark: #889ad9;
+ --color-header-back--dark: #1a1a23;
+ --color-scrollbar--dark: #dcb5f338;
+ --color-button-hover--dark: #9363af81;
+ --color-error: rgb(221, 46, 46);
+ --color-success: rgb(32, 158, 105);
+ --color-warn: #ebcc00;
+ --color-dim-line: #ededf060;
+ --color-search-border: #ededf02d;
+ --color-section-border--dark: rgba(255, 255, 255, 0.2);
+
+ /* Light Theme */
+ --color-primary--light: rgba(21, 21, 28, 0.8);
+ --color-primary-dim--light: rgba(21, 21, 28, 0.7);
+ --color-background--light: #ffffff;
+ --color-background-dim--light: #f0f0f8;
+ --color-accent--light: #a071bb;
+ --color-links--light: #4d5e9d;
+ --color-header-back--light: #2d2d37;
+ --color-scrollbar--light: #b589ce7c;
+ --color-button-hover--light: #9363af81;
+ --color-section-border--light: rgba(21, 21, 28, 0.2);
+
+ /* Code Blocks */
+ --color-pre-background--dark: #1a1a23;
+ --color-pre-primary--dark: rgba(255, 255, 255, 0.9);
+ --color-pre-background--light: #f0f0f8b0;
+ --color-pre-primary--light: rgba(21, 21, 28, 0.9);
+ --font-pre--mono: "JetBrains Mono", monospace;
+
+ /* Admonitions */
+ --color-warning-background: #ffdd0005;
+ --color-warning-background--title: #ffdd00e0;
+ --color-warning--title: #2b2b2b;
+ --color-important-background--title: #cd526a;
+ --color-important-background: #cd526a05;
+ --color-note-background--title: #269da1;
+ --color-note-background: #269da105;
+ --color-versionadd-background--title: #5aa569;
+ --color-versionadd-background: #5aa56905;
+ --color-versionchange-background--title: #616faf;
+ --color-versionchange-background: #616faf05;
+ --color-tip-background: #9f6fb505;
+ --color-tip-background--title: #9f6fb5;
+}
+
+* {
+ scrollbar-width: thin;
+}
+
+html {
+ scrollbar-width: auto!important;
+}
+
+body {
+ font-family: "Lato", sans-serif;
+ font-size: 0.9em;
+ font-weight: 300;
+ color: var(--color-primary--light);
+ background-color: var(--color-background--light);
+ scrollbar-color: var(--color-scrollbar--light)
+ var(--color-background-dim--light);
+ word-wrap: break-word;
+}
+
+.modal__container {
+ background-color: var(--color-background--light)!important;
+}
+
+strong {
+ font-weight: 700;
+ font-size: 1.05em;
+}
+
+h2, h1 {
+ padding: 0 0 0.25rem 0;
+ border-bottom: 1px solid var(--color-section-border--light);
+}
+
+.simple > li {
+ line-height: 1.4;
+ padding: 0.3em 0;
+}
+
+pre,
+code {
+ font-family: var(--font-pre--mono);
+ font-weight: 300;
+ font-size: 0.9em;
+}
+
+code {
+ padding: 0.18rem 0.22rem;
+}
+
+.page-toc {
+ font-family: "Lato", sans-serif!important;
+}
+
+.page-toc > ul > li > ul > li > a > code > span {
+ font-family: "Lato", sans-serif!important;
+}
+
+li > a > code > .pre {
+ font-family: "Lato", sans-serif!important;
+}
+
+body > div.container-fluid > div > div > div:nth-child(3) > nav > div > ul > li > ul > li > ul > li > a > code > span
+
+.form-control {
+ border-left: none !important;
+ border-color: transparent !important;
+}
+
+.input-group-text.bg-white {
+ border-color: transparent !important;
+}
+
+dl.field-list.simple > dt {
+ font-weight: 700;
+}
+
+dl.field-list.simple > dd {
+ padding: 0.5rem 0 0 2rem;
+}
+
+dl.field-list.simple > dd > ul {
+ margin-left: -2rem;
+}
+
+/* Colours Light */
+.input-group-text.bg-white {
+ background-color: var(--color-background-dim--light) !important;
+}
+
+.sidebar-container {
+ border-color: var(--color-background-dim--light) !important;
+}
+
+hr {
+ border-color: var(--color-background-dim--light) !important;
+}
+
+.btn-light {
+ background-color: var(--color-background-dim--light);
+ color: var(--color-primary--light);
+ border-color: var(--color-scrollbar--light);
+}
+
+.btn-light:hover {
+ background-color: var(--color-button-hover--light);
+}
+
+.btn:focus {
+ outline: none!important;
+ background-color: var(--color-button-hover--light)!important;
+}
+
+.btn:active {
+ outline: none;
+ background-color: var(--color-button-hover--light);
+}
+
+.border-top {
+ border-color: var(--color-background-dim--light) !important;
+}
+
+table.docutils:not(.field-list) thead th {
+ background-color: var(--color-background-dim--light);
+}
+
+table.docutils:not(.field-list) tbody tr:nth-of-type(odd) {
+ background-color: var(--color-background--light);
+}
+
+table.docutils:not(.field-list) {
+ background-color: var(--color-background-dim--light);
+}
+
+td {
+ border-color: var(--color-button-hover--light) !important;
+}
+
+th {
+ border-color: var(--color-button-hover--light) !important;
+}
+
+.admonition.warning {
+ background-color: var(--color-warning-background) !important;
+ padding: 1rem 1rem 0 1rem!important;
+}
+
+.warning > .admonition-title {
+ background-color: var(--color-warning-background--title) !important;
+ color: var(--color-warning--title) !important;
+}
+
+.admonition.important {
+ background-color: var(--color-important-background) !important;
+ padding: 1rem 1rem 0 1rem!important;
+}
+
+.important > .admonition-title {
+ background-color: var(--color-important-background--title) !important;
+}
+
+.admonition.note {
+ background-color: var(--color-note-background) !important;
+ padding: 1rem 1rem 0 1rem!important;
+}
+
+.note > .admonition-title {
+ background-color: var(--color-note-background--title) !important;
+}
+
+.admonition.tip {
+ background-color: var(--color-tip-background) !important;
+ padding: 1rem 1rem 0 1rem!important;
+}
+
+.tip > .admonition-title {
+ background-color: var(--color-tip-background--title) !important;
+}
+
+.versionadded {
+ background-color: var(--color-versionadd-background) !important;
+}
+
+.versionmodified.added {
+ background-color: var(--color-versionadd-background--title) !important;
+}
+
+.versionchanged {
+ background-color: var(--color-versionchange-background) !important;
+}
+
+.versionmodified.changed {
+ background-color: var(--color-versionchange-background--title) !important;
+}
+
+a {
+ color: var(--color-links--light) !important;
+}
+
+a > code {
+ color: var(--color-links--light) !important;
+}
+
+.bg-primary {
+ background-color: var(--color-header-back--light) !important;
+}
+
+.sig {
+ background-color: var(--color-background-dim--light);
+}
+
+.sig-name {
+ color: var(--color-accent--light);
+}
+
+.sig-prename {
+ color: var(--color-accent--light);
+}
+
+.form-control {
+ background-color: var(--color-background-dim--light) !important;
+}
+
+.toc > ul:not(:last-child) {
+ border-color: var(--color-background-dim--light);
+}
+
+.sig-usagetable {
+ font-style: normal;
+ margin-top: 0.5rem;
+ padding-left: 1rem;
+ font-size: 0.9em;
+ font-weight: 400;
+ border-left: 4px solid var(--color-warning-background--title);
+ line-height: 1.6;
+}
+
+pre {
+ color: var(--color-pre-primary--light) !important;
+ background-color: var(--color-pre-background--light);
+}
+
+.toctree-expand {
+ background-color: var(--color-accent--light);
+}
+
+body.theme-dark {
+ color: var(--color-primary--dark);
+ background-color: #22222d;
+}
+
+body.theme-dark {
+ .modal__container {
+ background-color: var(--color-background--dark)!important;
+ }
+
+ h2, h1 {
+ border-bottom: 1px solid var(--color-section-border--dark)!important;
+ }
+
+ .toctree-expand {
+ background-color: var(--color-accent--light)!important;
+ }
+
+ code {
+ color: #bac4d9 !important;
+ background-color: var(--color-background-dim--dark);
+ }
+
+ .input-group-text.bg-white {
+ background-color: var(--color-background-dim--dark) !important;
+ }
+
+ .sidebar-container {
+ border-color: var(--color-background-dim--dark) !important;
+ }
+
+ hr {
+ border-color: var(--color-background-dim--dark) !important;
+ }
+
+ .btn-light {
+ background-color: var(--color-background-dim--dark);
+ color: var(--color-primary--dark);
+ border-color: var(--color-scrollbar--dark);
+ }
+
+ .btn-light:hover {
+ background-color: var(--color-button-hover--dark);
+ }
+
+ .btn:focus {
+ outline: none!important;
+ background-color: var(--color-button-hover--dark)!important;
+ }
+
+ .btn:active {
+ outline: none;
+ background-color: var(--color-button-hover--dark);
+ }
+
+ .border-top {
+ border-color: var(--color-background-dim--dark) !important;
+ }
+
+ table.docutils:not(.field-list) thead th {
+ background-color: var(--color-background-dim--dark);
+ }
+
+ table.docutils:not(.field-list) tbody tr:nth-of-type(odd) {
+ background-color: var(--color-background--dark);
+ }
+
+ table.docutils:not(.field-list) {
+ background-color: var(--color-background-dim--dark);
+ }
+
+ td {
+ border-color: var(--color-button-hover--dark) !important;
+ }
+
+ th {
+ border-color: var(--color-button-hover--dark) !important;
+ }
+
+ blockquote {
+ border-color: #2b4459!important;
+ }
+}
+
+.theme-dark {
+ /* HOVERXREF (TOOLTIP STYLES */
+ .tooltipster-content {
+ background-color: var(--color-background--dark);
+ color: var(--color-primary--dark) !important;
+ }
+
+ .copybtn {
+ top: .5em!important;
+ right: .5em!important;
+ opacity: .2!important;
+ }
+
+ .copybtn:hover {
+ opacity: 0.9!important;
+ }
+
+ .navbar-brand {
+ color: var(--color-primary--dark)!important;
+ }
+
+ strong {
+ color: #FFF;
+ }
+
+ a > strong {
+ color: var(--color-links--dark)!important;
+ }
+
+ .tooltipster-content > p {
+ color: var(--color-primary--dark) !important;
+ }
+
+ .tooltipster-box {
+ border-bottom: 2px solid var(--color-accent--dark) !important;
+ }
+
+ a {
+ color: var(--color-links--dark) !important;
+ }
+
+ a > code {
+ color: var(--color-links--dark) !important;
+ }
+
+ .bg-primary {
+ background-color: var(--color-header-back--dark) !important;
+ }
+
+ .sig {
+ background-color: var(--color-background-dim--dark);
+ }
+
+ .sig-name {
+ color: var(--color-accent--dark);
+ }
+
+ .sig-prename {
+ color: var(--color-accent--dark);
+ }
+
+ .form-control {
+ background-color: var(--color-background-dim--dark) !important;
+ }
+
+ .toc > ul:not(:last-child) {
+ border-color: var(--color-background-dim--dark);
+ }
+
+ pre {
+ color: var(--color-pre-primary--dark) !important;
+ background-color: var(--color-pre-background--dark) !important;
+ }
+
+ scrollbar-color: var(--color-scrollbar--dark)
+ var(--color-background-dim--dark) !important;
+
+ .sig-param {
+ color: var(--color-primary-dim--dark);
+ }
+}
+
+.error-tio {
+ font-weight: 600;
+ color: var(--color-error);
+}
+
+.error-tio::before {
+ /* biome-ignore lint/a11y/useGenericFontNames: ... */
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+ content: "\f06a";
+ margin-right: 0.5rem;
+}
+
+.success-tio {
+ font-weight: 600;
+ color: var(--color-success);
+}
+
+.success-tio::before {
+ /* biome-ignore lint/a11y/useGenericFontNames: ... */
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+ content: "\f00c";
+ margin-right: 0.5rem;
+}
+
+.warn-tio {
+ font-weight: 600;
+ color: var(--color-warn);
+}
+
+.warn-tio::before {
+ /* biome-ignore lint/a11y/useGenericFontNames: ... */
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+ content: "\f059";
+ margin-right: 0.5rem;
+}
+
+.sig {
+ font-size: 1em;
+ padding: 0.5rem;
+ word-spacing: 0.25rem;
+}
+
+.sig-param {
+ font-style: normal;
+ padding: 0 0.125rem;
+ color: var(--color-primary-dim--light);
+}
+
+.sig-paren {
+ padding: 0 0.125rem;
+}
+
+dt {
+ font-weight: 400;
+}
+
+a {
+ text-decoration: none;
+}
+
+.py.class > .sig {
+ padding: 0.75rem;
+ border-left: 1px solid var(--color-links--dark);
+}
+
+/* attribute tables */
+.py-attribute-table {
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+ margin: 0em 2em 1em 2em;
+ padding-top: 16px;
+}
+
+.py-attribute-table-column {
+ flex: 1 1 auto;
+}
+
+.py-attribute-table-column > span {
+ font-weight: bold;
+}
+
+main .py-attribute-table-column > ul {
+ list-style: none;
+ margin: 4px 0px;
+ padding-left: 0;
+ font-size: 0.95em;
+}
+
+.py-attribute-table-entry {
+ margin: 0;
+ padding: 2px 0;
+ padding-left: 0.2em;
+ border-left: 2px solid var(--color-links--dark);
+ display: flex;
+ line-height: 1.2em;
+}
+
+.py-attribute-table-entry > a {
+ padding-left: 0.5em;
+ flex-grow: 1;
+}
+
+.py-attribute-table-entry > a:hover {
+ text-decoration: none;
+}
+
+.py-attribute-table-entry:hover {
+ border-left: 2px solid var(--color-accent--dark);
+ text-decoration: none;
+}
+
+.py-attribute-table-badge {
+ flex-basis: 3em;
+ text-align: right;
+ font-size: 0.9em;
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* Fix indent on class/func */
+
+dl.py > dd {
+ margin: 0.75em 2em;
+}
+
+
+/* Fix code block admonitions */
+.codeB {
+ background-color: var(--color-dim-line);
+}
+
+.codeW {
+ border: 1px solid #a071bb31;
+ padding-bottom: 1rem!important;
+}
+
+.admonition-title.codeB {
+ padding: 0.25rem 1rem;
+ font-weight: 400;
+ width: 100%;
+}
+
+.admonition-title.codeB::before {
+ /* biome-ignore lint/a11y/useGenericFontNames: ... */
+ font-family: "Font Awesome 5 Free";
+ content: "\f121" !important;
+}
+
+.admonition.codeW {
+ overflow-x: auto;
+ margin-bottom: 2rem;
+}
+
+.admonition-title {
+ padding: .25rem .5rem;
+ font-weight: 600;
+}
+
+.versionmodified {
+ padding: .25rem .5rem;
+}
+
+/* Fix padding on meth/prop dd */
+dl.py > dd {
+ padding: 0.5rem 0;
+}
+
+/* Fix Search Box and Outline */
+.input-group {
+ border: 1px solid var(--color-search-border) !important;
+ border-radius: .25rem;
+}
+
+.input-group:focus-within {
+ outline: 1px solid var(--color-dim-line) !important;
+ border-radius: .25rem;
+}
+
+.form-control:focus {
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.input-group > .input-group-prepend:hover {
+ cursor: pointer;
+}
+
+[type="search"]::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZGVkZjA4MCIgc3Ryb2tlLXdpZHRoPSIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS14Ij48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxwYXRoIGQ9Im0xNSA5LTYgNiIvPjxwYXRoIGQ9Im05IDkgNiA2Ii8+PC9zdmc+);
+ color: var(--color-accent--light) !important;
+ height: 20px;
+ width: 20px;
+ background-repeat: no-repeat;
+ cursor: pointer;
+}
+
+/* HOVERXREF (TOOLTIP STYLES */
+.tooltipster-content {
+ background-color: var(--color-background--light);
+ color: var(--color-primary--light) !important;
+}
+
+.tooltipster-content > p {
+ color: var(--color-primary--light) !important;
+}
+
+.tooltipster-box {
+ border-bottom: 2px solid var(--color-accent--dark) !important;
+}
diff --git a/docs/_static/custom.js b/docs/_static/custom.js
new file mode 100644
index 00000000..85c4f3a6
--- /dev/null
+++ b/docs/_static/custom.js
@@ -0,0 +1,32 @@
+const tables = document.querySelectorAll(
+ ".py-attribute-table[data-move-to-id]",
+);
+for (const table of tables) {
+ const element = document.getElementById(
+ table.getAttribute("data-move-to-id"),
+ );
+ const parent = element.parentNode;
+
+ // insert ourselves after the element
+ parent.insertBefore(table, element.nextSibling);
+}
+
+const pres = document.querySelectorAll("pre");
+for (const pre_ of pres) {
+ pre_.classList.add("admonition", "codeW");
+}
+
+document.addEventListener("DOMContentLoaded", (e) => {
+ const search = document.getElementById("search-form");
+ search.id = "search-form_";
+
+ const button = search.querySelector(".input-group>.input-group-prepend");
+ button.addEventListener("click", (e) => {
+ const inp = document.getElementById("searchinput");
+ if (!inp.textContent) {
+ return;
+ }
+
+ search.submit();
+ });
+});
diff --git a/docs/_static/js/custom.js b/docs/_static/js/custom.js
deleted file mode 100644
index 435fe66c..00000000
--- a/docs/_static/js/custom.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const classes = document.getElementsByClassName("class");
-const tables = document.getElementsByClassName('py-attribute-table');
-
-for (let i = 0; i < classes.length; i++) {
- const parentSig = classes[i].getElementsByTagName('dt')[0];
- const table = tables[i];
-
- parentSig.classList.add(`parent-sig-${i}`);
- table.id = `attributable-${i}`
-
- $(`#attributable-${i}`).insertAfter( `.parent-sig-${i}` );
-}
\ No newline at end of file
diff --git a/docs/_static/logo.png b/docs/_static/logo.png
new file mode 100644
index 00000000..617c35a4
Binary files /dev/null and b/docs/_static/logo.png differ
diff --git a/docs/_static/logo_dark.png b/docs/_static/logo_dark.png
deleted file mode 100644
index 1fcb025f..00000000
Binary files a/docs/_static/logo_dark.png and /dev/null differ
diff --git a/docs/_static/logo_light.png b/docs/_static/logo_light.png
deleted file mode 100644
index 7c9b7235..00000000
Binary files a/docs/_static/logo_light.png and /dev/null differ
diff --git a/docs/_static/styles/furo.css b/docs/_static/styles/furo.css
deleted file mode 100644
index 05190da0..00000000
--- a/docs/_static/styles/furo.css
+++ /dev/null
@@ -1,2270 +0,0 @@
-body {
- font-size: .9em;
- --font-stack: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
- --font-stack--monospace: "SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;
- --font-size--normal: 100%;
- --font-size--small: 87.5%;
- --font-size--small--2: 81.25%;
- --font-size--small--3: 75%;
- --font-size--small--4: 62.5%;
- --sidebar-caption-font-size: var(--font-size--small--2);
- --sidebar-item-font-size: var(--font-size--small);
- --sidebar-search-input-font-size: var(--font-size--small);
- --toc-font-size: var(--font-size--small--3);
- --toc-font-size--mobile: var(--font-size--normal);
- --toc-title-font-size: var(--font-size--small--4);
- --admonition-font-size: 0.8125rem;
- --admonition-title-font-size: 0.8125rem;
- --code-font-size: var(--font-size--small--2);
- --api-font-size: var(--font-size--small);
- --header-height: calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);
- --header-padding: 0.5rem;
- --sidebar-tree-space-above: 1.5rem;
- --sidebar-caption-space-above: 1rem;
- --sidebar-item-line-height: 1rem;
- --sidebar-item-spacing-vertical: 0.5rem;
- --sidebar-item-spacing-horizontal: 1rem;
- --sidebar-item-height: calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);
- --sidebar-expander-width: var(--sidebar-item-height);
- --sidebar-search-space-above: 0.5rem;
- --sidebar-search-input-spacing-vertical: 0.5rem;
- --sidebar-search-input-spacing-horizontal: 0.5rem;
- --sidebar-search-input-height: 1rem;
- --sidebar-search-icon-size: var(--sidebar-search-input-height);
- --toc-title-padding: 0.25rem 0;
- --toc-spacing-vertical: 1.5rem;
- --toc-spacing-horizontal: 1.5rem;
- --toc-item-spacing-vertical: 0.4rem;
- --toc-item-spacing-horizontal: 1rem;
- --icon-search: url('data:image/svg+xml;charset=utf-8,');
- --icon-pencil: url('data:image/svg+xml;charset=utf-8,');
- --icon-abstract: url('data:image/svg+xml;charset=utf-8,');
- --icon-info: url('data:image/svg+xml;charset=utf-8,');
- --icon-flame: url('data:image/svg+xml;charset=utf-8,');
- --icon-question: url('data:image/svg+xml;charset=utf-8,');
- --icon-warning: url('data:image/svg+xml;charset=utf-8,');
- --icon-failure: url('data:image/svg+xml;charset=utf-8,');
- --icon-spark: url('data:image/svg+xml;charset=utf-8,');
- --color-admonition-title--caution: #ff9100;
- --color-admonition-title-background--caution: rgba(255,145,0,.1);
- --color-admonition-title--warning: #ff9100;
- --color-admonition-title-background--warning: rgba(255,145,0,.1);
- --color-admonition-title--danger: #ff5252;
- --color-admonition-title-background--danger: rgba(255,82,82,.1);
- --color-admonition-title--attention: #ff5252;
- --color-admonition-title-background--attention: rgba(255,82,82,.1);
- --color-admonition-title--error: #ff5252;
- --color-admonition-title-background--error: rgba(255,82,82,.1);
- --color-admonition-title--hint: #00c852;
- --color-admonition-title-background--hint: rgba(0,200,82,.1);
- --color-admonition-title--tip: #00c852;
- --color-admonition-title-background--tip: rgba(0,200,82,.1);
- --color-admonition-title--important: #00bfa5;
- --color-admonition-title-background--important: rgba(0,191,165,.1);
- --color-admonition-title--note: #00b0ff;
- --color-admonition-title-background--note: rgba(0,176,255,.1);
- --color-admonition-title--seealso: #448aff;
- --color-admonition-title-background--seealso: rgba(68,138,255,.1);
- --color-admonition-title--admonition-todo: grey;
- --color-admonition-title-background--admonition-todo: hsla(0,0%,50%,.1);
- --color-admonition-title: #651fff;
- --color-admonition-title-background: rgba(101,31,255,.1);
- --icon-admonition-default: var(--icon-abstract);
- --color-topic-title: #14b8a6;
- --color-topic-title-background: rgba(20,184,166,.1);
- --icon-topic-default: var(--icon-pencil);
- --color-problematic: #b30000;
- --color-foreground-primary: #000;
- --color-foreground-secondary: #5a5c63;
- --color-foreground-muted: #646776;
- --color-foreground-border: #878787;
- --color-background-primary: #fff;
- --color-background-secondary: #f8f9fb;
- --color-background-hover: #efeff4;
- --color-background-hover--transparent: #efeff400;
- --color-background-border: #eeebee;
- --color-announcement-background: #000000dd;
- --color-announcement-text: #eeebee;
- --color-brand-primary: #2962ff;
- --color-brand-content: #2a5adf;
- --color-api-background: var(--color-background-secondary);
- --color-api-background-hover: var(--color-background-hover);
- --color-api-overall: var(--color-foreground-secondary);
- --color-api-name: var(--color-problematic);
- --color-api-pre-name: var(--color-problematic);
- --color-api-paren: var(--color-foreground-secondary);
- --color-api-keyword: var(--color-foreground-primary);
- --color-highlight-on-target: #ffc;
- --color-inline-code-background: var(--color-background-secondary);
- --color-highlighted-background: #def;
- --color-highlighted-text: var(--color-foreground-primary);
- --color-guilabel-background: #ddeeff80;
- --color-guilabel-border: #bedaf580;
- --color-guilabel-text: var(--color-foreground-primary);
- --color-admonition-background: transparent;
- --color-table-header-background: var(--color-background-secondary);
- --color-table-border: var(--color-background-border);
- --color-card-border: var(--color-background-secondary);
- --color-card-background: transparent;
- --color-card-marginals-background: var(--color-background-secondary);
- --color-header-background: var(--color-background-primary);
- --color-header-border: var(--color-background-border);
- --color-header-text: var(--color-foreground-primary);
- --color-sidebar-background: var(--color-background-secondary);
- --color-sidebar-background-border: var(--color-background-border);
- --color-sidebar-brand-text: var(--color-foreground-primary);
- --color-sidebar-caption-text: var(--color-foreground-muted);
- --color-sidebar-link-text: var(--color-foreground-secondary);
- --color-sidebar-link-text--top-level: var(--color-brand-primary);
- --color-sidebar-item-background: var(--color-sidebar-background);
- --color-sidebar-item-background--current: var( --color-sidebar-item-background );
- --color-sidebar-item-background--hover: linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);
- --color-sidebar-item-expander-background: transparent;
- --color-sidebar-item-expander-background--hover: var( --color-background-hover );
- --color-sidebar-search-text: var(--color-foreground-primary);
- --color-sidebar-search-background: var(--color-background-secondary);
- --color-sidebar-search-background--focus: var(--color-background-primary);
- --color-sidebar-search-border: var(--color-background-border);
- --color-sidebar-search-icon: var(--color-foreground-muted);
- --color-toc-background: var(--color-background-primary);
- --color-toc-title-text: var(--color-foreground-muted);
- --color-toc-item-text: var(--color-foreground-secondary);
- --color-toc-item-text--hover: var(--color-foreground-primary);
- --color-toc-item-text--active: var(--color-brand-primary);
- --color-content-foreground: var(--color-foreground-primary);
- --color-content-background: transparent;
- --color-link: var(--color-brand-content);
- --color-link--hover: var(--color-brand-content);
- --color-link-underline: var(--color-background-border);
- --color-link-underline--hover: var(--color-foreground-border)
-}
-
-.highlight {
- background-color: #F6F6F6;
-}
-
-.class > .sig-object {
- border-left: #0e84b5 solid 3px;
- border-radius: 3px 0 0 0;
-}
-
-/* Attributable stuff */
-.py-attribute-table {
- display: flex;
- flex-wrap: wrap;
- background-color: var(--color-api-background);
- border-radius: 0 0 3px 3px;
- border-left: #0e84b5 solid 3px;
- margin-bottom: 2rem;
-}
-
-.py-attribute-table-entry {
- list-style: None;
-}
-
-.py-attribute-table-column {
- flex: .5;
-}
-
-.py-attribute-table-column > span {
- font-weight: bold;
- margin-left: 1rem;
-}
-
-.py-attribute-table-badge {
- font-weight: bold;
- margin-right: .5rem;
-}
-
-/* Sidebar Stuff */
-.sidebar-drawer {
- width: 0!important;
- background: var(--color-sidebar-background);
- border-right: 1px solid var(--color-sidebar-background-border);
- box-sizing: border-box;
- display: flex;
- justify-content: flex-end;
- min-width: 15em;
-}
-
-/* Main Div */
-main {
- display: block
-}
-
-.main {
- display: flex;
- flex: 1;
- justify-content: space-evenly!important;
-}
-
-/* Content Div */
-.content {
- width: 50%!important;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- padding: 0 3em;
-}
-
-/* Index Page Stuff */
-.featuring-logo {
- width: 50%;
- align-self: flex-start;
- margin-top: 4rem;
-}
-
-.featuring-hr {
- margin-top: 2rem;
- opacity: 40%;
-}
-
-.index-featuring {
- margin-top: 2rem!important;
-}
-
-.index-featuring-list {
- list-style: none;
- margin-top: 1rem;
-}
-
-.index-featuring-list > li::before {
- content: '- ';
- font-weight: bold;
-}
-
-.index-featuring-list > li::before {
- content: '- ';
- font-weight: bold;
-}
-
-.index-section-header {
- margin-top: 2rem;
-}
-
-.index-apis-wrapper {
- display: flex;
- flex-direction: row;
-}
-
-.index-apis-section {
- margin-top: 2rem;
- margin-right: 4rem;
-}
-
-.index-display-none {
- display: none;
-}
-
-.index-changelog > .caption[role=heading] {
- display: none;
-}
-
-.index-changelog > ul > li::marker {
- content: '- ';
- font-weight: bold;
-}
-
-html {
- -webkit-text-size-adjust: 100%;
- line-height: 1.15
-}
-
-body {
- margin: 0
-}
-
-h1 {
- font-size: 2em;
- margin: .67em 0
-}
-
-hr {
- box-sizing: content-box;
- height: 0;
- overflow: visible
-}
-
-pre {
- font-family: monospace,monospace;
- font-size: 1em
-}
-
-a {
- background-color: transparent
-}
-
-abbr[title] {
- border-bottom: none;
- text-decoration: underline;
- text-decoration: underline dotted
-}
-
-b,strong {
- font-weight: bolder
-}
-
-code,kbd,samp {
- font-family: monospace,monospace;
- font-size: 1em
-}
-
-sub,sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline
-}
-
-sub {
- bottom: -.25em
-}
-
-sup {
- top: -.5em
-}
-
-img {
- border-style: none
-}
-
-button,input,optgroup,select,textarea {
- font-family: inherit;
- font-size: 100%;
- line-height: 1.15;
- margin: 0
-}
-
-button,input {
- overflow: visible
-}
-
-button,select {
- text-transform: none
-}
-
-[type=button],[type=reset],[type=submit],button {
- -webkit-appearance: button
-}
-
-[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner {
- border-style: none;
- padding: 0
-}
-
-[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring {
- outline: 1px dotted ButtonText
-}
-
-fieldset {
- padding: .35em .75em .625em
-}
-
-legend {
- box-sizing: border-box;
- color: inherit;
- display: table;
- max-width: 100%;
- padding: 0;
- white-space: normal
-}
-
-progress {
- vertical-align: baseline
-}
-
-textarea {
- overflow: auto
-}
-
-[type=checkbox],[type=radio] {
- box-sizing: border-box;
- padding: 0
-}
-
-[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button {
- height: auto
-}
-
-[type=search] {
- -webkit-appearance: textfield;
- outline-offset: -2px
-}
-
-[type=search]::-webkit-search-decoration {
- -webkit-appearance: none
-}
-
-::-webkit-file-upload-button {
- -webkit-appearance: button;
- font: inherit
-}
-
-details {
- display: block
-}
-
-summary {
- display: list-item
-}
-
-[hidden],template {
- display: none
-}
-
-@media print {
- .content-icon-container,.headerlink,.mobile-header,.related-pages {
- display: none!important
- }
-
- .highlight {
- border: .1pt solid var(--color-foreground-border)
- }
-}
-
-.visually-hidden {
- clip: rect(0,0,0,0)!important;
- border: 0!important;
- height: 1px!important;
- margin: -1px!important;
- overflow: hidden!important;
- padding: 0!important;
- position: absolute!important;
- white-space: nowrap!important;
- width: 1px!important
-}
-
-:-moz-focusring {
- outline: auto
-}
-
-.only-light {
- display: block!important
-}
-
-html body .only-dark {
- display: none!important
-}
-
-@media not print {
- body[data-theme=dark] {
- --color-problematic: #ee5151;
- --color-foreground-primary: #ffffffcc;
- --color-foreground-secondary: #9ca0a5;
- --color-foreground-muted: #81868d;
- --color-foreground-border: #666;
- --color-background-primary: #131416;
- --color-background-secondary: #1a1c1e;
- --color-background-hover: #1e2124;
- --color-background-hover--transparent: #1e212400;
- --color-background-border: #303335;
- --color-announcement-background: #000000dd;
- --color-announcement-text: #eeebee;
- --color-brand-primary: #2b8cee;
- --color-brand-content: #368ce2;
- --color-highlighted-background: #083563;
- --color-guilabel-background: #08356380;
- --color-guilabel-border: #13395f80;
- --color-api-keyword: var(--color-foreground-secondary);
- --color-highlight-on-target: #330;
- --color-admonition-background: #18181a;
- --color-card-border: var(--color-background-secondary);
- --color-card-background: #18181a;
- --color-card-marginals-background: var(--color-background-hover)
- }
-
- html body[data-theme=dark] .only-light {
- display: none!important
- }
-
- body[data-theme=dark] .only-dark {
- display: block!important
- }
-
- @media(prefers-color-scheme: dark) {
- body:not([data-theme=light]) {
- --color-problematic:#ee5151;
- --color-foreground-primary: #ffffffcc;
- --color-foreground-secondary: #9ca0a5;
- --color-foreground-muted: #81868d;
- --color-foreground-border: #666;
- --color-background-primary: #131416;
- --color-background-secondary: #1a1c1e;
- --color-background-hover: #1e2124;
- --color-background-hover--transparent: #1e212400;
- --color-background-border: #303335;
- --color-announcement-background: #000000dd;
- --color-announcement-text: #eeebee;
- --color-brand-primary: #2b8cee;
- --color-brand-content: #368ce2;
- --color-highlighted-background: #083563;
- --color-guilabel-background: #08356380;
- --color-guilabel-border: #13395f80;
- --color-api-keyword: var(--color-foreground-secondary);
- --color-highlight-on-target: #330;
- --color-admonition-background: #18181a;
- --color-card-border: var(--color-background-secondary);
- --color-card-background: #18181a;
- --color-card-marginals-background: var(--color-background-hover)
- }
-
- html body:not([data-theme=light]) .only-light {
- display: none!important
- }
-
- body:not([data-theme=light]) .only-dark {
- display: block!important
- }
- }
-}
-
-body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto,body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light {
- display: block
-}
-
-body {
- font-family: var(--font-stack)
-}
-
-code,kbd,pre,samp {
- font-family: var(--font-stack--monospace)
-}
-
-body {
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale
-}
-
-article {
- line-height: 1.5
-}
-
-h1,h2,h3,h4,h5,h6 {
- border-radius: .5rem;
- font-weight: 700;
- line-height: 1.25;
- margin: .5rem -.5rem;
- padding-left: .5rem;
- padding-right: .5rem
-}
-
-h1+p,h2+p,h3+p,h4+p,h5+p,h6+p {
- margin-top: 0
-}
-
-h1 {
- font-size: 2.5em;
- margin-bottom: 1rem
-}
-
-h1,h2 {
- margin-top: 1.75rem
-}
-
-h2 {
- font-size: 2em
-}
-
-h3 {
- font-size: 1.5em
-}
-
-h4 {
- font-size: 1.25em
-}
-
-h5 {
- font-size: 1.125em
-}
-
-h6 {
- font-size: 1em
-}
-
-small {
- font-size: 80%;
- opacity: 75%
-}
-
-p {
- margin-bottom: .75rem;
- margin-top: .5rem
-}
-
-hr.docutils {
- background-color: var(--color-background-border);
- border: 0;
- height: 1px;
- margin: 2rem 0;
- padding: 0
-}
-
-.centered {
- text-align: center
-}
-
-a {
- color: var(--color-link);
- text-decoration: underline;
- -webkit-text-decoration-color: var(--color-link-underline);
- text-decoration-color: var(--color-link-underline)
-}
-
-a:hover {
- color: var(--color-link--hover);
- -webkit-text-decoration-color: var(--color-link-underline--hover);
- text-decoration-color: var(--color-link-underline--hover)
-}
-
-a.muted-link {
- color: inherit
-}
-
-a.muted-link:hover {
- color: var(--color-link);
- -webkit-text-decoration-color: var(--color-link-underline--hover);
- text-decoration-color: var(--color-link-underline--hover)
-}
-
-html {
- overflow-x: hidden;
- overflow-y: scroll;
- scroll-behavior: smooth
-}
-
-.sidebar-scroll,.toc-scroll,article[role=main] * {
- scrollbar-color: var(--color-foreground-border) transparent;
- scrollbar-width: thin
-}
-
-.sidebar-scroll::-webkit-scrollbar,.toc-scroll::-webkit-scrollbar,article[role=main] ::-webkit-scrollbar {
- height: .25rem;
- width: .25rem
-}
-
-.sidebar-scroll::-webkit-scrollbar-thumb,.toc-scroll::-webkit-scrollbar-thumb,article[role=main] ::-webkit-scrollbar-thumb {
- background-color: var(--color-foreground-border);
- border-radius: .125rem
-}
-
-body,html {
- background: var(--color-background-primary);
- color: var(--color-foreground-primary);
- height: 100%
-}
-
-article {
- background: var(--color-content-background);
- color: var(--color-content-foreground)
-}
-
-.page {
- display: flex;
- min-height: 100%
-}
-
-.mobile-header {
- background-color: var(--color-header-background);
- border-bottom: 1px solid var(--color-header-border);
- color: var(--color-header-text);
- display: none;
- height: var(--header-height);
- width: 100%;
- z-index: 10
-}
-
-.mobile-header.scrolled {
- border-bottom: none;
- box-shadow: 0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)
-}
-
-.mobile-header .header-center a {
- color: var(--color-header-text);
- text-decoration: none
-}
-
-.main {
- display: flex;
- flex: 1;
- justify-content: space-evenly;
-}
-
-.sidebar-container,.toc-drawer {
- box-sizing: border-box;
- width: 15em
-}
-
-.toc-drawer {
- background: var(--color-toc-background);
- padding-right: 1rem
-}
-
-.sidebar-sticky,.toc-sticky {
- display: flex;
- flex-direction: column;
- height: min(100%,100vh);
- height: 100vh;
- position: -webkit-sticky;
- position: sticky;
- top: 0
-}
-
-.sidebar-scroll,.toc-scroll {
- flex-grow: 1;
- flex-shrink: 1;
- overflow: auto;
- scroll-behavior: smooth
-}
-
-.icon {
- display: inline-block;
- height: 1rem;
- width: 1rem
-}
-
-.icon svg {
- height: 100%;
- width: 100%
-}
-
-.announcement {
- align-items: center;
- background-color: var(--color-announcement-background);
- color: var(--color-announcement-text);
- display: flex;
- height: var(--header-height);
- overflow-x: auto
-}
-
-.announcement+.page {
- min-height: calc(100% - var(--header-height))
-}
-
-.announcement-content {
- box-sizing: border-box;
- min-width: 100%;
- padding: .5rem;
- text-align: center;
- white-space: nowrap
-}
-
-.announcement-content a {
- color: var(--color-announcement-text);
- -webkit-text-decoration-color: var(--color-announcement-text);
- text-decoration-color: var(--color-announcement-text)
-}
-
-.announcement-content a:hover {
- color: var(--color-announcement-text);
- -webkit-text-decoration-color: var(--color-link--hover);
- text-decoration-color: var(--color-link--hover)
-}
-
-.no-js .theme-toggle-container {
- display: none
-}
-
-.theme-toggle-container {
- vertical-align: middle
-}
-
-.theme-toggle {
- background: 0 0;
- border: none;
- cursor: pointer;
- padding: 0
-}
-
-.theme-toggle svg {
- color: var(--color-foreground-primary);
- display: none;
- height: 1rem;
- vertical-align: middle;
- width: 1rem
-}
-
-.theme-toggle-header {
- float: left;
- padding: 1rem .5rem
-}
-
-.nav-overlay-icon,.toc-overlay-icon {
- cursor: pointer;
- display: none
-}
-
-.nav-overlay-icon .icon,.toc-overlay-icon .icon {
- color: var(--color-foreground-secondary);
- height: 1rem;
- width: 1rem
-}
-
-.nav-overlay-icon,.toc-header-icon {
- align-items: center;
- justify-content: center
-}
-
-.toc-content-icon {
- height: 1.5rem;
- width: 1.5rem
-}
-
-.content-icon-container {
- display: flex;
- float: right;
- gap: .5rem;
- margin-bottom: 1rem;
- margin-left: 1rem;
- margin-top: 1.5rem
-}
-
-.content-icon-container .edit-this-page svg {
- color: inherit;
- height: 1rem;
- width: 1rem
-}
-
-.sidebar-toggle {
- display: none;
- position: absolute
-}
-
-.sidebar-toggle[name=__toc] {
- left: 20px
-}
-
-.sidebar-toggle:checked {
- left: 40px
-}
-
-.overlay {
- background-color: rgba(0,0,0,.54);
- height: 0;
- opacity: 0;
- position: fixed;
- top: 0;
- transition: width 0ms,height 0ms,opacity .25s ease-out;
- width: 0
-}
-
-.sidebar-overlay {
- z-index: 20
-}
-
-.toc-overlay {
- z-index: 40
-}
-
-.sidebar-drawer {
- transition: left .25s ease-in-out;
- z-index: 30
-}
-
-.toc-drawer {
- transition: right .25s ease-in-out;
- z-index: 50
-}
-
-#__navigation:checked~.sidebar-overlay {
- height: 100%;
- opacity: 1;
- width: 100%
-}
-
-#__navigation:checked~.page .sidebar-drawer {
- left: 0;
- top: 0
-}
-
-#__toc:checked~.toc-overlay {
- height: 100%;
- opacity: 1;
- width: 100%
-}
-
-#__toc:checked~.page .toc-drawer {
- right: 0;
- top: 0
-}
-
-.back-to-top {
- background: var(--color-background-primary);
- border-radius: 1rem;
- box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 #6b728080;
- display: none;
- font-size: .8125rem;
- left: 0;
- margin-left: 50%;
- padding: .5rem .75rem .5rem .5rem;
- position: fixed;
- text-decoration: none;
- top: calc(var(--header-height) + .5rem);
- transform: translateX(-50%);
- z-index: 1
-}
-
-.back-to-top svg {
- fill: var(--color-foreground-primary);
- display: inline-block;
- height: 1rem;
- width: 1rem
-}
-
-.back-to-top span {
- margin-left: .25rem
-}
-
-.show-back-to-top .back-to-top {
- align-items: center;
- display: flex
-}
-
-@media(min-width: 97em) {
- html {
- font-size:110%
- }
-}
-
-@media(max-width: 82em) {
- .toc-content-icon {
- display:flex
- }
-
- .toc-drawer {
- border-left: 1px solid var(--color-background-muted);
- height: 100vh;
- position: fixed;
- right: -15em;
- top: 0
- }
-
- .toc-tree {
- border-left: none;
- font-size: var(--toc-font-size--mobile)
- }
-
- .sidebar-drawer {
- width: calc(50% - 18.5em)
- }
-}
-
-@media(max-width: 67em) {
- .nav-overlay-icon {
- display:flex
- }
-
- .sidebar-drawer {
- height: 100vh;
- left: -15em;
- position: fixed;
- top: 0;
- width: 15em
- }
-
- .toc-header-icon {
- display: flex
- }
-
- .theme-toggle-content,.toc-content-icon {
- display: none
- }
-
- .theme-toggle-header {
- display: block
- }
-
- .mobile-header {
- align-items: center;
- display: flex;
- justify-content: space-between;
- position: -webkit-sticky;
- position: sticky;
- top: 0
- }
-
- .mobile-header .header-left,.mobile-header .header-right {
- display: flex;
- height: var(--header-height);
- padding: 0 var(--header-padding)
- }
-
- .mobile-header .header-left label,.mobile-header .header-right label {
- height: 100%;
- width: 100%
- }
-
- :target {
- scroll-margin-top: var(--header-height)
- }
-
- .page {
- flex-direction: column;
- justify-content: center
- }
-
- .content {
- margin-left: auto;
- margin-right: auto
- }
-}
-
-@media(max-width: 52em) {
- .content {
- overflow-x:auto;
- width: 100%!important;
- }
-}
-
-@media(max-width: 46em) {
- .content {
- padding:0 1em
- }
-
- article div.sidebar {
- float: none;
- margin: 1rem 0;
- width: 100%
- }
-}
-
-.admonition,.topic {
- background: var(--color-admonition-background);
- border-radius: .2rem;
- box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);
- font-size: var(--admonition-font-size);
- margin: 1rem auto;
- overflow: hidden;
- padding: 0 .5rem .5rem;
- page-break-inside: avoid
-}
-
-.admonition>:nth-child(2),.topic>:nth-child(2) {
- margin-top: 0
-}
-
-.admonition>:last-child,.topic>:last-child {
- margin-bottom: 0
-}
-
-p.admonition-title,p.topic-title {
- font-size: var(--admonition-title-font-size);
- font-weight: 500;
- line-height: 1.3;
- margin: 0 -.5rem .5rem;
- padding: .4rem .5rem .4rem 2rem;
- position: relative
-}
-
-p.admonition-title:before,p.topic-title:before {
- content: "";
- height: 1rem;
- left: .5rem;
- position: absolute;
- width: 1rem
-}
-
-p.admonition-title {
- background-color: var(--color-admonition-title-background)
-}
-
-p.admonition-title:before {
- background-color: var(--color-admonition-title);
- -webkit-mask-image: var(--icon-admonition-default);
- mask-image: var(--icon-admonition-default);
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat
-}
-
-p.topic-title {
- background-color: var(--color-topic-title-background)
-}
-
-p.topic-title:before {
- background-color: var(--color-topic-title);
- -webkit-mask-image: var(--icon-topic-default);
- mask-image: var(--icon-topic-default);
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat
-}
-
-.admonition {
- border-left: .2rem solid var(--color-admonition-title)
-}
-
-.admonition.caution {
- border-left-color: var(--color-admonition-title--caution)
-}
-
-.admonition.caution>.admonition-title {
- background-color: var(--color-admonition-title-background--caution)
-}
-
-.admonition.caution>.admonition-title:before {
- background-color: var(--color-admonition-title--caution);
- -webkit-mask-image: var(--icon-spark);
- mask-image: var(--icon-spark)
-}
-
-.admonition.warning {
- border-left-color: var(--color-admonition-title--warning)
-}
-
-.admonition.warning>.admonition-title {
- background-color: var(--color-admonition-title-background--warning)
-}
-
-.admonition.warning>.admonition-title:before {
- background-color: var(--color-admonition-title--warning);
- -webkit-mask-image: var(--icon-warning);
- mask-image: var(--icon-warning)
-}
-
-.admonition.danger {
- border-left-color: var(--color-admonition-title--danger)
-}
-
-.admonition.danger>.admonition-title {
- background-color: var(--color-admonition-title-background--danger)
-}
-
-.admonition.danger>.admonition-title:before {
- background-color: var(--color-admonition-title--danger);
- -webkit-mask-image: var(--icon-spark);
- mask-image: var(--icon-spark)
-}
-
-.admonition.attention {
- border-left-color: var(--color-admonition-title--attention)
-}
-
-.admonition.attention>.admonition-title {
- background-color: var(--color-admonition-title-background--attention)
-}
-
-.admonition.attention>.admonition-title:before {
- background-color: var(--color-admonition-title--attention);
- -webkit-mask-image: var(--icon-warning);
- mask-image: var(--icon-warning)
-}
-
-.admonition.error {
- border-left-color: var(--color-admonition-title--error)
-}
-
-.admonition.error>.admonition-title {
- background-color: var(--color-admonition-title-background--error)
-}
-
-.admonition.error>.admonition-title:before {
- background-color: var(--color-admonition-title--error);
- -webkit-mask-image: var(--icon-failure);
- mask-image: var(--icon-failure)
-}
-
-.admonition.hint {
- border-left-color: var(--color-admonition-title--hint)
-}
-
-.admonition.hint>.admonition-title {
- background-color: var(--color-admonition-title-background--hint)
-}
-
-.admonition.hint>.admonition-title:before {
- background-color: var(--color-admonition-title--hint);
- -webkit-mask-image: var(--icon-question);
- mask-image: var(--icon-question)
-}
-
-.admonition.tip {
- border-left-color: var(--color-admonition-title--tip)
-}
-
-.admonition.tip>.admonition-title {
- background-color: var(--color-admonition-title-background--tip)
-}
-
-.admonition.tip>.admonition-title:before {
- background-color: var(--color-admonition-title--tip);
- -webkit-mask-image: var(--icon-info);
- mask-image: var(--icon-info)
-}
-
-.admonition.important {
- border-left-color: var(--color-admonition-title--important)
-}
-
-.admonition.important>.admonition-title {
- background-color: var(--color-admonition-title-background--important)
-}
-
-.admonition.important>.admonition-title:before {
- background-color: var(--color-admonition-title--important);
- -webkit-mask-image: var(--icon-flame);
- mask-image: var(--icon-flame)
-}
-
-.admonition.note {
- border-left-color: var(--color-admonition-title--note)
-}
-
-.admonition.note>.admonition-title {
- background-color: var(--color-admonition-title-background--note)
-}
-
-.admonition.note>.admonition-title:before {
- background-color: var(--color-admonition-title--note);
- -webkit-mask-image: var(--icon-pencil);
- mask-image: var(--icon-pencil)
-}
-
-.admonition.seealso {
- border-left-color: var(--color-admonition-title--seealso)
-}
-
-.admonition.seealso>.admonition-title {
- background-color: var(--color-admonition-title-background--seealso)
-}
-
-.admonition.seealso>.admonition-title:before {
- background-color: var(--color-admonition-title--seealso);
- -webkit-mask-image: var(--icon-info);
- mask-image: var(--icon-info)
-}
-
-.admonition.admonition-todo {
- border-left-color: var(--color-admonition-title--admonition-todo)
-}
-
-.admonition.admonition-todo>.admonition-title {
- background-color: var(--color-admonition-title-background--admonition-todo)
-}
-
-.admonition.admonition-todo>.admonition-title:before {
- background-color: var(--color-admonition-title--admonition-todo);
- -webkit-mask-image: var(--icon-pencil);
- mask-image: var(--icon-pencil)
-}
-
-.admonition-todo>.admonition-title {
- text-transform: uppercase
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd {
- margin-left: 2rem
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child {
- margin-top: .125rem
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child {
- margin-bottom: .75rem
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt {
- font-size: var(--font-size--small);
- text-transform: uppercase
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty {
- margin-bottom: .5rem
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul {
- margin-left: -1.2rem
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2) {
- margin-top: 0
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty {
- margin-bottom: 0;
- margin-top: 0
-}
-
-dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt {
- color: var(--color-api-overall)
-}
-
-.sig {
- background: var(--color-api-background);
- border-radius: .25rem;
- font-family: var(--font-stack--monospace);
- font-size: var(--api-font-size);
- font-weight: 700;
- padding: .25rem .5rem .25rem 3em;
- text-indent: -2.5em
-}
-
-.sig:hover {
- background: var(--color-api-background-hover)
-}
-
-.sig a.reference .viewcode-link {
- font-weight: 400;
- width: 3.5rem
-}
-
-.sig span.pre {
- overflow-wrap: anywhere
-}
-
-em.property {
- font-style: normal
-}
-
-em.property:first-child {
- color: var(--color-api-keyword)
-}
-
-.sig-name {
- color: var(--color-api-name)
-}
-
-.sig-prename {
- color: var(--color-api-pre-name);
- font-weight: 400
-}
-
-.sig-paren {
- color: var(--color-api-paren)
-}
-
-.sig-param {
- font-style: normal
-}
-
-.versionmodified {
- font-style: italic
-}
-
-div.deprecated p,div.versionadded p,div.versionchanged p {
- margin-bottom: .125rem;
- margin-top: .125rem
-}
-
-.viewcode-back,.viewcode-link {
- float: right;
- text-align: right
-}
-
-.code-block-caption,article p.caption,table>caption {
- font-size: var(--font-size--small);
- text-align: center
-}
-
-.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text {
- font-size: var(--font-size--small);
- margin-bottom: 0;
- text-align: initial;
- text-transform: uppercase
-}
-
-.toctree-wrapper.compound>ul {
- margin-bottom: 0;
- margin-top: 0
-}
-
-code.literal {
- background: var(--color-inline-code-background);
- border-radius: .2em;
- font-size: var(--font-size--small--2);
- padding: .1em .2em
-}
-
-p code.literal {
- border: 1px solid var(--color-background-border)
-}
-
-div[class*=" highlight-"],div[class^=highlight-] {
- display: flex;
- margin: 1em 0
-}
-
-div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre {
- margin: 0;
- padding: 0
-}
-
-article[role=main] .highlight pre {
- line-height: 1.5
-}
-
-.highlight pre,pre.literal-block {
- font-size: var(--code-font-size);
- overflow: auto;
- padding: .625rem .875rem
-}
-
-pre.literal-block {
- background-color: var(--color-code-background);
- border-radius: .2rem;
- color: var(--color-code-foreground);
- margin-bottom: 1rem;
- margin-top: 1rem
-}
-
-.highlight {
- border-radius: .2rem;
- width: 100%
-}
-
-.highlight .gp,.highlight span.linenos {
- pointer-events: none;
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none
-}
-
-.highlight .hll {
- display: block;
- margin-left: -.875rem;
- margin-right: -.875rem;
- padding-left: .875rem;
- padding-right: .875rem
-}
-
-.code-block-caption {
- background-color: var(--color-code-background);
- border-bottom: 1px solid;
- border-bottom-color: var(--color-background-border);
- border-radius: .25rem;
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
- border-left-color: var(--color-background-border);
- border-right-color: var(--color-background-border);
- border-top-color: var(--color-background-border);
- color: var(--color-code-foreground);
- display: flex;
- font-weight: 300;
- padding: .625rem .875rem
-}
-
-.code-block-caption+div[class] {
- margin-top: 0
-}
-
-.code-block-caption+div[class] pre {
- border-top-left-radius: 0;
- border-top-right-radius: 0
-}
-
-.highlighttable {
- display: block;
- width: 100%
-}
-
-.highlighttable tbody {
- display: block
-}
-
-.highlighttable tr {
- display: flex
-}
-
-.highlighttable td.linenos {
- background-color: var(--color-code-background);
- border-bottom-left-radius: .2rem;
- border-top-left-radius: .2rem;
- color: var(--color-code-foreground);
- padding: .625rem 0 .625rem .875rem
-}
-
-.highlighttable .linenodiv {
- box-shadow: -.0625rem 0 var(--color-foreground-border) inset;
- font-size: var(--code-font-size);
- padding-right: .875rem
-}
-
-.highlighttable td.code {
- display: block;
- flex: 1;
- overflow: hidden;
- padding: 0
-}
-
-.highlighttable td.code .highlight {
- border-bottom-left-radius: 0;
- border-top-left-radius: 0
-}
-
-.highlight span.linenos {
- box-shadow: -.0625rem 0 var(--color-foreground-border) inset;
- display: inline-block;
- margin-right: .875rem;
- padding-left: 0;
- padding-right: .875rem
-}
-
-.footnote-reference {
- font-size: var(--font-size--small--4);
- vertical-align: super
-}
-
-dl.footnote.brackets {
- color: var(--color-foreground-secondary);
- display: grid;
- font-size: var(--font-size--small);
- grid-template-columns: -webkit-max-content auto;
- grid-template-columns: max-content auto
-}
-
-dl.footnote.brackets dt {
- margin: 0
-}
-
-dl.footnote.brackets dt>.fn-backref {
- margin-left: .25rem
-}
-
-dl.footnote.brackets dt:after {
- content: ":"
-}
-
-dl.footnote.brackets dt .brackets:before {
- content: "["
-}
-
-dl.footnote.brackets dt .brackets:after {
- content: "]"
-}
-
-dl.footnote.brackets dd {
- margin: 0;
- padding: 0 1rem
-}
-
-img {
- box-sizing: border-box;
- height: auto;
- max-width: 100%
-}
-
-article .figure,article figure {
- border-radius: .2rem;
- margin: 0
-}
-
-article .figure :last-child,article figure :last-child {
- margin-bottom: 0
-}
-
-article .align-left {
- clear: left;
- float: left;
- margin: 0 1rem 1rem
-}
-
-article .align-right {
- clear: right;
- float: right;
- margin: 0 1rem 1rem
-}
-
-article .align-center,article .align-default {
- display: block;
- margin-left: auto;
- margin-right: auto;
- text-align: center
-}
-
-article table.align-default {
- display: table;
- text-align: initial
-}
-
-.domainindex-jumpbox,.genindex-jumpbox {
- border-bottom: 1px solid var(--color-background-border);
- border-top: 1px solid var(--color-background-border);
- padding: .25rem
-}
-
-.domainindex-section h2,.genindex-section h2 {
- margin-bottom: .5rem;
- margin-top: .75rem
-}
-
-.domainindex-section ul,.genindex-section ul {
- margin-bottom: 0;
- margin-top: 0
-}
-
-ol,ul {
- margin-bottom: 1rem;
- margin-top: 1rem;
- padding-left: 1.2rem
-}
-
-ol li>p:first-child,ul li>p:first-child {
- margin-bottom: .25rem;
- margin-top: .25rem
-}
-
-ol li>p:last-child,ul li>p:last-child {
- margin-top: .25rem
-}
-
-ol li>ol,ol li>ul,ul li>ol,ul li>ul {
- margin-bottom: .5rem;
- margin-top: .5rem
-}
-
-.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul {
- margin-bottom: 0;
- margin-top: 0
-}
-
-.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt {
- font-weight: 500;
- margin-top: .25rem
-}
-
-.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt {
- margin-top: 0
-}
-
-.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before {
- content: ":";
- margin-left: .2rem;
- margin-right: .2rem
-}
-
-.field-list dd>p:first-child,.field-list dd ul,.option-list dd>p:first-child,.option-list dd ul,dl.footnote dd>p:first-child,dl.footnote dd ul,dl.glossary dd>p:first-child,dl.glossary dd ul,dl.simple dd>p:first-child,dl.simple dd ul,dl:not([class]) dd>p:first-child,dl:not([class]) dd ul {
- margin-top: .125rem
-}
-
-.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul {
- margin-bottom: .125rem
-}
-
-.math-wrapper {
- overflow-x: auto;
- width: 100%
-}
-
-div.math {
- position: relative;
- text-align: center
-}
-
-div.math .headerlink,div.math:focus .headerlink {
- display: none
-}
-
-div.math:hover .headerlink {
- display: inline-block
-}
-
-div.math span.eqno {
- position: absolute;
- right: .5rem;
- top: 50%;
- transform: translateY(-50%);
- z-index: 1
-}
-
-abbr[title] {
- cursor: help
-}
-
-.problematic {
- color: var(--color-problematic)
-}
-
-kbd:not(.compound) {
- background-color: var(--color-background-secondary);
- border: 1px solid var(--color-foreground-border);
- border-radius: .2rem;
- box-shadow: 0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);
- color: var(--color-foreground-primary);
- display: inline-block;
- font-size: var(--font-size--small--3);
- margin: 0 .2rem;
- padding: 0 .2rem;
- vertical-align: text-bottom
-}
-
-blockquote {
- background: var(--color-background-secondary);
- border-left: 4px solid var(--color-background-border);
- margin-left: 0;
- margin-right: 0;
- padding: .5rem 1rem
-}
-
-blockquote .attribution {
- font-weight: 600;
- text-align: right
-}
-
-blockquote.highlights,blockquote.pull-quote {
- font-size: 1.25em
-}
-
-blockquote.epigraph,blockquote.pull-quote {
- border-left-width: 0;
- border-radius: .5rem
-}
-
-blockquote.highlights {
- background: 0 0;
- border-left-width: 0
-}
-
-p .reference img {
- vertical-align: middle
-}
-
-p.rubric {
- font-size: 1.125em;
- font-weight: 700;
- line-height: 1.25
-}
-
-article .sidebar {
- background-color: var(--color-background-secondary);
- border: 1px solid var(--color-background-border);
- border-radius: .2rem;
- clear: right;
- float: right;
- margin-left: 1rem;
- margin-right: 0;
- width: 30%
-}
-
-article .sidebar>* {
- padding-left: 1rem;
- padding-right: 1rem
-}
-
-article .sidebar>ol,article .sidebar>ul {
- padding-left: 2.2rem
-}
-
-article .sidebar .sidebar-title {
- border-bottom: 1px solid var(--color-background-border);
- font-weight: 500;
- margin: 0;
- padding: .5rem 1rem
-}
-
-.table-wrapper {
- margin-bottom: .5rem;
- margin-top: 1rem;
- overflow-x: auto;
- padding: .2rem .2rem .75rem;
- width: 100%
-}
-
-table.docutils {
- border-collapse: collapse;
- border-radius: .2rem;
- border-spacing: 0;
- box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)
-}
-
-table.docutils th {
- background: var(--color-table-header-background)
-}
-
-table.docutils td,table.docutils th {
- border-bottom: 1px solid var(--color-table-border);
- border-left: 1px solid var(--color-table-border);
- border-right: 1px solid var(--color-table-border);
- padding: 0 .25rem
-}
-
-table.docutils td p,table.docutils th p {
- margin: .25rem
-}
-
-table.docutils td:first-child,table.docutils th:first-child {
- border-left: none
-}
-
-table.docutils td:last-child,table.docutils th:last-child {
- border-right: none
-}
-
-:target {
- scroll-margin-top: .5rem
-}
-
-@media(max-width: 67em) {
- :target {
- scroll-margin-top:calc(.5rem + var(--header-height))
- }
-
- section>span:target {
- scroll-margin-top: calc(.8rem + var(--header-height))
- }
-}
-
-.headerlink {
- font-weight: 100;
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none
-}
-
-.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink {
- margin-left: .5rem;
- visibility: hidden
-}
-
-.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink {
- visibility: visible
-}
-
-.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref {
- color: inherit;
- -webkit-text-decoration-line: none;
- text-decoration-line: none
-}
-
-figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink {
- visibility: visible
-}
-
-:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type {
- background-color: var(--color-highlight-on-target)
-}
-
-:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal {
- background-color: transparent
-}
-
-.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption {
- background-color: var(--color-highlight-on-target)
-}
-
-dt:target {
- background-color: var(--color-highlight-on-target)!important
-}
-
-.footnote-reference:target,.footnote>dt:target+dd {
- background-color: var(--color-highlight-on-target)
-}
-
-.guilabel {
- background-color: var(--color-guilabel-background);
- border: 1px solid var(--color-guilabel-border);
- border-radius: .5em;
- color: var(--color-guilabel-text);
- font-size: .9em;
- padding: 0 .3em
-}
-
-footer {
- display: flex;
- flex-direction: column;
- font-size: var(--font-size--small);
- margin-top: 2rem
-}
-
-.bottom-of-page {
- align-items: center;
- border-top: 1px solid var(--color-background-border);
- color: var(--color-foreground-secondary);
- display: flex;
- justify-content: space-between;
- line-height: 1.5;
- margin-top: 1rem;
- padding-bottom: 1rem;
- padding-top: 1rem
-}
-
-@media(max-width: 46em) {
- .bottom-of-page {
- flex-direction:column-reverse;
- gap: .25rem;
- text-align: center
- }
-}
-
-.bottom-of-page .left-details {
- font-size: var(--font-size--small)
-}
-
-.bottom-of-page .right-details {
- display: flex;
- flex-direction: column;
- gap: .25rem;
- text-align: right
-}
-
-.bottom-of-page .icons {
- display: flex;
- font-size: 1rem;
- gap: .25rem;
- justify-content: flex-end
-}
-
-.bottom-of-page .icons a {
- text-decoration: none
-}
-
-.bottom-of-page .icons img,.bottom-of-page .icons svg {
- font-size: 1.125rem;
- height: 1em;
- width: 1em
-}
-
-.related-pages a {
- align-items: center;
- display: flex;
- text-decoration: none
-}
-
-.related-pages a:hover .page-info .title {
- color: var(--color-link);
- text-decoration: underline;
- -webkit-text-decoration-color: var(--color-link-underline);
- text-decoration-color: var(--color-link-underline)
-}
-
-.related-pages a svg,.related-pages a svg>use {
- color: var(--color-foreground-border);
- flex-shrink: 0;
- height: .75rem;
- margin: 0 .5rem;
- width: .75rem
-}
-
-.related-pages a.next-page {
- clear: right;
- float: right;
- max-width: 50%;
- text-align: right
-}
-
-.related-pages a.prev-page {
- clear: left;
- float: left;
- max-width: 50%
-}
-
-.related-pages a.prev-page svg {
- transform: rotate(180deg)
-}
-
-.page-info {
- display: flex;
- flex-direction: column;
- overflow-wrap: anywhere
-}
-
-.next-page .page-info {
- align-items: flex-end
-}
-
-.page-info .context {
- align-items: center;
- color: var(--color-foreground-muted);
- display: flex;
- font-size: var(--font-size--small);
- padding-bottom: .1rem;
- text-decoration: none
-}
-
-ul.search {
- list-style: none;
- padding-left: 0
-}
-
-ul.search li {
- border-bottom: 1px solid var(--color-background-border);
- padding: 1rem 0
-}
-
-[role=main] .highlighted {
- background-color: var(--color-highlighted-background);
- color: var(--color-highlighted-text)
-}
-
-.sidebar-brand {
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);
- text-decoration: none
-}
-
-.sidebar-brand-text {
- color: var(--color-sidebar-brand-text);
- font-size: 1.5rem;
- overflow-wrap: break-word
-}
-
-.sidebar-brand-text,.sidebar-logo-container {
- margin: var(--sidebar-item-spacing-vertical) 0
-}
-
-.sidebar-logo {
- display: block;
- margin: 0 auto;
- max-width: 100%
-}
-
-.sidebar-search-container {
- align-items: center;
- background: var(--color-sidebar-search-background);
- display: flex;
- margin-top: var(--sidebar-search-space-above);
- position: relative
-}
-
-.sidebar-search-container:focus-within,.sidebar-search-container:hover {
- background: var(--color-sidebar-search-background--focus)
-}
-
-.sidebar-search-container:before {
- background-color: var(--color-sidebar-search-icon);
- content: "";
- height: var(--sidebar-search-icon-size);
- left: var(--sidebar-item-spacing-horizontal);
- -webkit-mask-image: var(--icon-search);
- mask-image: var(--icon-search);
- position: absolute;
- width: var(--sidebar-search-icon-size)
-}
-
-.sidebar-search {
- background: 0 0;
- border: none;
- border-bottom: 1px solid var(--color-sidebar-search-border);
- border-top: 1px solid var(--color-sidebar-search-border);
- box-sizing: border-box;
- color: var(--color-sidebar-search-foreground);
- padding: var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));
- width: 100%;
- z-index: 10
-}
-
-.sidebar-search:focus {
- outline: none
-}
-
-.sidebar-search::-moz-placeholder {
- font-size: var(--sidebar-search-input-font-size)
-}
-
-.sidebar-search:-ms-input-placeholder {
- font-size: var(--sidebar-search-input-font-size)
-}
-
-.sidebar-search::placeholder {
- font-size: var(--sidebar-search-input-font-size)
-}
-
-#searchbox .highlight-link {
- margin: 0;
- padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;
- text-align: center
-}
-
-#searchbox .highlight-link a {
- color: var(--color-sidebar-search-icon);
- font-size: var(--font-size--small--2)
-}
-
-.sidebar-tree {
- font-size: var(--sidebar-item-font-size);
- margin-bottom: var(--sidebar-item-spacing-vertical);
- margin-top: var(--sidebar-tree-space-above)
-}
-
-.sidebar-tree ul {
- display: flex;
- flex-direction: column;
- list-style: none;
- margin-bottom: 0;
- margin-top: 0;
- padding: 0
-}
-
-.sidebar-tree li {
- margin: 0;
- position: relative
-}
-
-.sidebar-tree li>ul {
- margin-left: var(--sidebar-item-spacing-horizontal)
-}
-
-.sidebar-tree .icon,.sidebar-tree .reference {
- color: var(--color-sidebar-link-text)
-}
-
-.sidebar-tree .reference {
- box-sizing: border-box;
- display: inline-block;
- height: 100%;
- line-height: var(--sidebar-item-line-height);
- overflow-wrap: anywhere;
- padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);
- text-decoration: none;
- width: 100%
-}
-
-.sidebar-tree .reference:hover {
- background: var(--color-sidebar-item-background--hover)
-}
-
-.sidebar-tree .reference.external:after {
- color: var(--color-sidebar-link-text);
- content: url(data:image/svg+xml;charset=utf-8;base64,PHN2ZyB3aWR0aD0nMTInIGhlaWdodD0nMTInIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgdmlld0JveD0nMCAwIDI0IDI0JyBzdHJva2Utd2lkdGg9JzEuNScgc3Ryb2tlPScjNjA3RDhCJyBmaWxsPSdub25lJyBzdHJva2UtbGluZWNhcD0ncm91bmQnIHN0cm9rZS1saW5lam9pbj0ncm91bmQnPjxwYXRoIGQ9J00wIDBoMjR2MjRIMHonIHN0cm9rZT0nbm9uZScvPjxwYXRoIGQ9J00xMSA3SDZhMiAyIDAgMCAwLTIgMnY5YTIgMiAwIDAgMCAyIDJoOWEyIDIgMCAwIDAgMi0ydi01TTEwIDE0IDIwIDRNMTUgNGg1djUnLz48L3N2Zz4=);
- margin: 0 .25rem;
- vertical-align: middle
-}
-
-.sidebar-tree .current-page>.reference {
- font-weight: 700
-}
-
-.sidebar-tree label {
- align-items: center;
- cursor: pointer;
- display: flex;
- height: var(--sidebar-item-height);
- justify-content: center;
- position: absolute;
- right: 0;
- top: 0;
- width: var(--sidebar-expander-width)
-}
-
-.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text {
- color: var(--color-sidebar-caption-text);
- font-size: var(--sidebar-caption-font-size);
- font-weight: 700;
- margin: var(--sidebar-caption-space-above) 0 0;
- padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);
- text-transform: uppercase
-}
-
-.sidebar-tree li.has-children>.reference {
- padding-right: var(--sidebar-expander-width)
-}
-
-.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon {
- color: var(--color-sidebar-link-text--top-level)
-}
-
-.sidebar-tree label {
- background: var(--color-sidebar-item-expander-background)
-}
-
-.sidebar-tree label:hover {
- background: var(--color-sidebar-item-expander-background--hover)
-}
-
-.sidebar-tree .current>.reference {
- background: var(--color-sidebar-item-background--current)
-}
-
-.sidebar-tree .current>.reference:hover {
- background: var(--color-sidebar-item-background--hover)
-}
-
-.toctree-checkbox {
- display: none;
- position: absolute
-}
-
-.toctree-checkbox~ul {
- display: none
-}
-
-.toctree-checkbox~label .icon svg {
- transform: rotate(90deg)
-}
-
-.toctree-checkbox:checked~ul {
- display: block
-}
-
-.toctree-checkbox:checked~label .icon svg {
- transform: rotate(-90deg)
-}
-
-.toc-title-container {
- padding: var(--toc-title-padding);
- padding-top: var(--toc-spacing-vertical)
-}
-
-.toc-title {
- color: var(--color-toc-title-text);
- font-size: var(--toc-title-font-size);
- padding-left: var(--toc-spacing-horizontal);
- text-transform: uppercase
-}
-
-.no-toc {
- display: none
-}
-
-.toc-tree-container {
- padding-bottom: var(--toc-spacing-vertical)
-}
-
-.toc-tree {
- border-left: 1px solid var(--color-background-border);
- font-size: var(--toc-font-size);
- line-height: 1.3;
- padding-left: calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))
-}
-
-.toc-tree>ul>li:first-child {
- padding-top: 0
-}
-
-.toc-tree>ul>li:first-child>ul {
- padding-left: 0
-}
-
-.toc-tree>ul>li:first-child>a {
- display: none
-}
-
-.toc-tree ul {
- list-style-type: none;
- margin-bottom: 0;
- margin-top: 0;
- padding-left: var(--toc-item-spacing-horizontal)
-}
-
-.toc-tree li {
- padding-top: var(--toc-item-spacing-vertical)
-}
-
-.toc-tree li.scroll-current>.reference {
- color: var(--color-toc-item-text--active);
- font-weight: 700
-}
-
-.toc-tree .reference {
- color: var(--color-toc-item-text);
- overflow-wrap: anywhere;
- text-decoration: none
-}
-
-.toc-scroll {
- max-height: 100vh;
- overflow-y: scroll
-}
-
-.text-align\:left>p {
- text-align: left
-}
-
-.text-align\:center>p {
- text-align: center
-}
-
-.text-align\:right>p {
- text-align: right
-}
diff --git a/docs/conf.py b/docs/conf.py
index fbe11f6b..bf4818bf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -15,26 +15,28 @@
# sys.path.insert(0, os.path.abspath('.'))
+import os
+
# -- Project information -----------------------------------------------------
import re
-import os
import sys
+
sys.path.insert(0, os.path.abspath("."))
sys.path.insert(0, os.path.abspath(".."))
-sys.path.append(os.path.abspath("extensions"))
+sys.path.append(os.path.abspath("_extensions"))
on_rtd = os.environ.get("READTHEDOCS") == "True"
project = "TwitchIO"
-copyright = "2024, TwitchIO"
+copyright = "2017-Current, PythonistaGuild"
author = "PythonistaGuild"
# The full version, including alpha/beta/rc tags
-release = ''
-with open('../twitchio/__init__.py') as f:
- release = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1)
-
+release = ""
+with open("../twitchio/__init__.py") as f:
+ release = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) # type: ignore
+version = release
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
@@ -43,44 +45,57 @@
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.extlinks",
- "sphinxcontrib.asyncio",
+ "sphinx.ext.napoleon",
"sphinx.ext.intersphinx",
+ "details",
"attributetable",
- "sphinxext.opengraph",
- "sphinx.ext.napoleon"
-]
-
-# OpenGraph Meta Tags
-ogp_image = "https://raw.githubusercontent.com/TwitchIO/TwitchIO/master/logo.png"
-ogp_description = "Documentation for TwitchIO, the asynchronous Python wrapper for Twitch.tv."
-ogp_site_url = "https://twitchio.dev/"
-ogp_custom_meta_tags = [
- '',
- ''
+ "hoverxref.extension",
+ "sphinxcontrib_trio",
+ "sphinx_wagtail_theme",
+ "sig_prefix",
+ "exc_hierarchy"
]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ["_templates"]
+# templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
-
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
-html_theme = "furo"
+html_theme = "sphinx_wagtail_theme"
+html_last_updated_fmt = "%b %d, %Y"
# html_logo = "logo.png"
-html_theme_options = {
- "sidebar_hide_name": True,
- "light_logo": "logo_light.png",
- "dark_logo": "logo_dark.png",
-}
+html_theme_options = dict(
+ project_name="Documentation",
+ github_url="https://github.com/PythonistaGuild/TwitchIO/tree/dev/3.0/docs/",
+ logo="logo.png",
+ logo_alt="TwitchIO",
+ logo_height=120,
+ logo_url="/",
+ logo_width=120,
+ footer_links=",".join(
+ [
+ "GitHub|https://github.com/PythonistaGuild/TwitchIO",
+ "Discord|https://discord.gg/RAKc3HF",
+ "Documentation|https://twitchio.dev",
+ ]
+ ),
+ header_links = "Guides|/guides/index.html, Examples|https://github.com/PythonistaGuild/TwitchIO/tree/master/examples, Commands|/exts/commands/index.html, Routines|/exts/routines/index.html",
+)
+
+copyright = "2017 - Present, PythonistaGuild"
+html_show_copyright = True
+html_show_sphinx = False
+
+html_last_updated_fmt = "%b %d, %Y - %H:%M:%S"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
@@ -90,16 +105,14 @@
# These paths are either relative to html_static_path
# or fully qualified paths (eg. https://...)
-html_css_files = ["styles/furo.css"]
-html_js_files = ["js/custom.js"]
-
+html_css_files = ["custom.css", "codeblocks.css"]
+html_js_files = ["custom.js"]
-napoleon_use_rtype = False
napoleon_google_docstring = False
napoleon_numpy_docstring = True
napoleon_include_private_with_doc = False
napoleon_include_special_with_doc = False
-autodoc_member_order = "groupwise"
+autodoc_member_order = "bysource"
rst_prolog = """
.. |coro| replace:: This function is a |corourl|_.
@@ -107,6 +120,9 @@
.. |corourl| replace:: *coroutine*
.. _corourl: https://docs.python.org/3/library/asyncio-task.html#coroutine
.. |deco| replace:: This function is a **decorator**.
+.. |aiter| replace:: **This function returns a** :class:`~twitchio.HTTPAsyncIterator`
+.. |token_for| replace:: An optional User-ID, or PartialUser object, that will be used to find an appropriate managed user token for this request. See: :meth:`~twitchio.Client.add_token` to add managed tokens to the client.
+.. |extmodule| replace:: ...
"""
# The suffix(es) of source filenames.
@@ -115,7 +131,51 @@
# source_suffix = ['.rst', '.md']
source_suffix = ".rst"
-intersphinx_mapping = {"py": ("https://docs.python.org/3", None)}
+intersphinx_mapping = {
+ "py": ("https://docs.python.org/3", None),
+}
+
+extlinks = {
+ "tioissue": ("https://github.com/PythonistaGuild/Twitchio/issues/%s", "GH-%s"),
+ "es-docs": ("https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#%s", "Twitch Eventsub %s")
+}
+
+
+# Hoverxref Settings...
+hoverxref_auto_ref = True
+hoverxref_intersphinx = ["py"]
+
+hoverxref_role_types = {
+ "hoverxref": "modal",
+ "ref": "modal",
+ "confval": "tooltip",
+ "mod": "tooltip",
+ "class": "tooltip",
+ "attr": "tooltip",
+ "func": "tooltip",
+ "meth": "tooltip",
+ "exc": "tooltip",
+}
+
+hoverxref_roles = list(hoverxref_role_types.keys())
+hoverxref_domains = ["py"]
+hoverxref_default_type = "tooltip"
+hoverxref_tooltip_theme = ["tooltipster-punk", "tooltipster-shadow", "tooltipster-shadow-custom"]
+
pygments_style = "sphinx"
pygments_dark_style = "monokai"
+
+
+html_experimental_html5_writer = True
+
+
+def autodoc_skip_member(app, what, name, obj, skip, options):
+ exclusions = ("__weakref__", "__doc__", "__module__", "__dict__", "__init__")
+ exclude = name in exclusions
+
+ return True if exclude else None
+
+
+def setup(app):
+ app.connect("autodoc-skip-member", autodoc_skip_member)
diff --git a/docs/exts/commands.rst b/docs/exts/commands.rst
deleted file mode 100644
index 171be6af..00000000
--- a/docs/exts/commands.rst
+++ /dev/null
@@ -1,652 +0,0 @@
-.. currentmodule:: twitchio.ext.commands
-
-.. _commands-ref:
-
-Commands Ext
-===========================
-
-
-Walkthrough
-------------
-
-The commands ext is meant purely for creating twitch chatbots. It gives you powerful tools, including dynamic loading/unloading/reloading
-of modules, organization of code using Cogs, and of course, commands.
-
-The base of this ext revolves around the :class:`Bot`. :class:`Bot` is a subclass of :class:`~twitchio.Client`, which means it has all the functionality
-of :class:`~twitchio.Client`, while also adding on the features needed for command handling.
-
-.. note::
- Because :class:`Bot` is a subclass of :class:`~twitchio.Client`, you do not need to use :class:`~twitchio.Client` at all.
- All of the functionality you're looking for is contained within :class:`Bot`.
- The only exception for this rule is when using the :ref:`Eventsub Ext `.
-
-To set up your bot for commands, the first thing we'll do is create a :class:`Bot`.
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!")
-
- bot.run()
-
-:class:`Bot` has two required arguments, ``token`` and ``prefix``. ``token`` is the same as for :class:`~twitchio.Client`,
-and ``prefix`` is a new argument, specific to commands. You can pass many different things as a prefix, for example:
-
-.. code-block:: python
-
- import twitchio
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!")
-
- bot = commands.Bot(token="...", prefix=("!", "?"))
-
- def prefix_callback(bot: commands.Bot, message: twitchio.Message) -> str:
- if message.channel.name == "iamtomahawkx":
- return "!"
- elif message.channel.name == "chillymosh":
- return "?"
- else:
- return ">>"
-
- bot = commands.Bot(token="...", prefix=prefix_callback)
-
- bot.run()
-
-All of those methods are valid prefixes, you can even pass an async function if needed. For this demo, we'll stick to using ``!``.
-We'll also be passing 3 initial channels to our bot, so that we can send commands right away on them:
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- bot.run()
-
-___
-
-To create a command, we'll use the following code:
-
-.. code-block:: python
-
- async def cookie(ctx: commands.Context) -> None:
- await ctx.send(f"{ctx.author.name} gets a cookie!")
-
-Every command takes a ``ctx`` argument, which gives you information on the command, who called it, from what channel, etc.
-You can read more about the ctx argument :ref:`Here `.
-
-Once we've made our function, we can tie it into our bot like this:
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command()
- async def cookie(ctx: commands.Context) -> None:
- await ctx.send(f"{ctx.author.name} gets a cookie!")
-
- bot.run()
-
-And then we can use it like this:
-
-.. image:: /images/commands_basic_1.png
-
-We've made use of a decorator here to make the ``cookie`` function a command that will be called
-whenever someone types ``!cookie`` in one of our twitch channels. But sometimes we'll want our function to be named something different
-than our command, or we'll want aliases so that multiple things trigger our command. We can do that by passing arguments to the decorator, like so:
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context) -> None:
- await ctx.send(f"{ctx.author.name} gets a cookie!")
-
- bot.run()
-
-Now our command can be triggered with any of ``!cookie``, ``!cookies``, or ``!biscuits``. But it `cannot` be triggered with ``!cookie_command``:
-
-.. image:: /images/commands_basic_2.png
-
-You may notice that if you try to run ``!cookie_command``, you get an error in your console about the command not being found.
-Don't worry, we'll hide that later, when we cover error handling.
-
-___
-
-Now let's say we want to take an argument for our command. We want to specify how many cookies the bot will give out.
-Fortunately, twitchio has that functionality built right in! We can simply add an argument to our function, and the argument will be added.
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context, amount) -> None:
- await ctx.send(f"{ctx.author.name} gets {amount} cookie(s)!")
-
- bot.run()
-
-.. image:: /images/commands_arguments_1.png
-
-Now, you'll notice that I passed ``words?`` as the argument in the image, and the code handled it fine.
-While it's good that it didn't error, we actually want it to error here, as our code should only take numbers!
-Good news, twitchio's argument handling goes beyond simple positional arguments. We can use python's typehints to tell the parser to **only** accept integers:
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context, amount: int) -> None:
- await ctx.send(f"{ctx.author.name} gets {amount} cookie(s)!")
-
- bot.run()
-
-.. image:: /images/commands_arguments_2.png
-
-Good, the command didn't accept the word where the number should be.
-We've got a messy error in our console, that looks like this:
-
-.. code::
-
- twitchio.ext.commands.errors.ArgumentParsingFailed: Invalid argument parsed at `amount` in command `cookie`. Expected type got .
-
-but we'll clean that up when we cover error handling.
-
-Twitchio allows for many kinds of typehints to be used, including built in types like ``str`` (the default), ``int``, and ``bool``.
-It also allows for some Twitchio models to be hinted. For instance, you can grab another user like this:
-
-.. code-block:: python
-
- import twitchio
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context, amount: int, user: twitchio.User) -> None:
- await ctx.send(f"{user.name} gets {amount} cookie(s)!")
-
- bot.run()
-
-.. image:: /images/commands_arguments_3.png
-
-Note that an error is raised for the last message, because "anfkednfowinoi" does not exist.
-
-.. code-block::
-
- twitchio.ext.commands.errors.BadArgument: User 'anfkednfowinoi' was not found.
-
-The built in models that you can use include:
-
-- :class:`~twitchio.PartialChatter` - cache independent.
-- :class:`~twitchio.Chatter` - dependent on cache, will fail if the user is not cached.
-- :class:`~twitchio.PartialUser` - makes an API call, use :class:`~twitchio.PartialChatter` instead when possible.
-- :class:`~twitchio.User` - makes an API call, use :class:`~twitchio.Chatter` instead when possible.
-- :class:`~twitchio.Channel` - another channel that your bot has joined.
-- :class:`~twitchio.Clip` - takes a clip URL.
-
-.. note::
- The :class:`~twitchio.User` / :class:`~twitchio.PartialUser` converters do make an API call, so they should only be used
- in cases where you need to ensure the user exists (as an error will be raised when they don't exist).
- For most usages of finding another user, you can simply use ``str`` or :class:`twitchio.PartialChatter`.
-
- Because of this downside, we'll be using :class:`~twitchio.PartialChatter` for the remainder of this walkthrough.
-
-___
-
-Now, let's say we want to have the option to pass a chatter, but we want it to be optional. If a chatter isn't passed, we use the author instead.
-We can accomplish this through the use of Python's ``typing`` module:
-
-.. code-block:: python
-
- import twitchio
- from typing import Optional
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context, amount: int, user: Optional[twitchio.PartialChatter]) -> None:
- if user is None:
- user = ctx.author
-
- await ctx.send(f"{user.name} gets {amount} cookie(s)!")
-
- bot.run()
-
-If you're on Python 3.10+, you could also structure it like this:
-
-.. code-block:: python
-
- import twitchio
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="cookie", aliases=("cookies", "biscuits"))
- async def cookie_command(ctx: commands.Context, amount: int, user: twitchio.PartialChatter | None) -> None:
- if user is None:
- user = ctx.author
-
- await ctx.send(f"{user.name} gets {amount} cookie(s)!")
-
- bot.run()
-
-.. image:: /images/commands_arguments_4.png
-
-With that 3.10 syntax in mind, we could also replace that ``None`` for another type. Maybe we want a clip, or any URL.
-We could accomplish this using the Union syntax (as it's known). We'll make use of ``yarl`` here to parse URLs.
-
-.. note::
- If you're using anything below 3.10, you can use ``typing.Union`` as a substitute for that syntax, like so:
-
- .. code-block:: python
-
- from typing import Union
-
- def foo(argument: Union[str, int]) -> None:
- ...
-
-At the same time, we'll introduce custom converters. While the library handles basic types and certain twitch types for you,
-you may wish to make your own converters at some point. The library allows you to do this by passing a callable function to the typehint.
-Additionally, you can use ``typing.Annotated`` to transform the argument for the type checker. This feature was introduced in Python 3.9,
-if you wish to use this feature on lower versions consider installing ``typing_extensions`` to use it from there.
-Using Annotated is not required, however it will help your type checker distinguish between converters and types.
-
-Lets take a look at custom converters and Annotated:
-
-.. code-block:: python
-
- import yarl
- import twitchio
- from typing import Annotated
- from twitchio.ext import commands
-
- def url_converter(ctx: commands.Context, arg: str) -> yarl.URL:
- return yarl.URL(arg) # this will raise if its an invalid URL.
-
- @bot.command(name="share")
- async def share_command(ctx: commands.Context, url: Annotated[yarl.URL, url_converter]) -> None:
- await ctx.send(f"{ctx.author.name} wants to share a link on {url.host}: {url}")
-
-Now that we've seen how custom converters work, let's combine them with the Union syntax to create a command that
-will take either a :class:`~twitchio.Clip` or a URL.
-I've spread the command definition out over multiple lines to make it more readable.
-
-.. code-block:: python
-
- import yarl
- import twitchio
- from typing import Annotated
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- def url_converter(ctx: commands.Context, arg: str) -> yarl.URL:
- return yarl.URL(arg) # this will raise if its an invalid URL.
-
- @bot.command(name="share")
- async def share_command(
- ctx: commands.Context,
- url: twitchio.Clip | Annotated[yarl.URL, url_converter]
- ) -> None:
- if isinstance(url, twitchio.Clip):
- await ctx.send(f"{ctx.author.name} wants to share a clip from {url.broadcaster.name}: {url.url}")
- else:
- await ctx.send(f"{ctx.author.name} wants to share a link on {url.host}: {url}")
-
- bot.run()
-
-
-.. image:: /images/commands_arguments_5.png
-
-___
-
-Let's take a look at the different ways you can pass strings to your commands.
-We'll use this example code:
-
-.. code-block:: python
-
- import twitchio
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="echo")
- async def echo(ctx: commands.Context, phrase: str, other_phrase: str | None) -> None:
- response = f"Echo! {phrase}"
- if other_phrase:
- response += f". You also said: {other_phrase}"
-
- await ctx.send(response)
-
- bot.run()
-
-At it's most basic, we can simply pass a word, and get a word back:
-
-.. image:: /images/commands_parsing_1.png
-
-However what do we do when we want to pass a sentence or multiple words to one argument?
-If change nothing here, and add a second word, we'll get some unwanted behaviour:
-
-.. image:: /images/commands_parsing_2.png
-
-However, there are two workarounds we can do.
-
-First, we can tell our users to quote their argument:
-
-.. image:: /images/commands_parsing_3.png
-
-However, if we want to work around it on the bot side, we can change our code to use a special *positional only* argument.
-In python, positional only arguments are ones that you must specify explicitly when calling the function.
-However, twitchio interprets them to mean "pass me the rest of the input". This means that you can only have **one** of these arguments.
-This must also be the last argument, because it consumes the rest of the input.
-
-Let's see how this would look:
-
-.. code-block:: python
-
- import twitchio
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- @bot.command(name="echo")
- async def echo(ctx: commands.Context, *, phrase: str) -> None:
- response = f"Echo! {phrase}"
-
- await ctx.send(response)
-
- bot.run()
-
-And how it turns out:
-
-.. image:: /images/commands_parsing_4.png
-
-
-___
-
-Now, let's clean up our errors a bit. To do this, we'll take a mix of the code examples from above:
-
-.. code-block:: python
-
- import yarl
- import twitchio
- from typing import Annotated
- from twitchio.ext import commands
-
- bot = commands.Bot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- def youtube_converter(ctx: commands.Context, arg: str) -> yarl.URL:
- url = yarl.URL(arg) # this will raise if its an invalid URL.
- if url.host not in ("youtube.com", "youtu.be"):
- raise RuntimeError("Not a youtube link!")
-
- return url
-
- @bot.command(name="share")
- async def share_command(
- ctx: commands.Context,
- url: Annotated[yarl.URL, youtube_converter],
- hype: int,
- *,
- comment: str
- ) -> None:
- hype_level = "hype" if 0 < hype < 5 else "very hype"
- await ctx.send(f"{ctx.author.name} wants to share a {hype_level} link on {url.host}: {comment}")
-
- bot.run()
-
-Currently, any errors that are raised will simply go directly into our console, but that's not really ideal behaviour.
-We want to choose errors to ignore, errors to print, and errors to send to the user. We can do this by subclassing our Bot, and overriding the command_error event.
-Let's take a look at that specifically:
-
-.. code-block:: python
-
- from twitchio.ext import commands
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- print(error)
-
- bot = MyBot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- # SNIP: command
-
- bot.run()
-
-Great, we've switched from the default behaviour to a custom behaviour. However, we can improve on it.
-
-There are a couple errors that you are garaunteed to encounter. CommandNotFound is probably the most annoying one, so let's start there:
-
-.. code-block:: python
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- if isinstance(error, commands.CommandNotFound):
- return
-
- print(error)
-
- # SNIP: everything else
-
-Now we will no longer see that pesky command not found error in our console every time someone mistypes a command.
-Next, we can handle some of the errors we saw earlier, like ArgumentParsingFailed:
-
-.. code-block:: python
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- if isinstance(error, commands.CommandNotFound):
- return
-
- elif isinstance(error, commands.ArgumentParsingFailed):
- await context.send(error.message)
-
- else:
- print(error)
-
- # SNIP: everything else
-
-Now we send argument parsing errors directly to the user, so they can adjust their input.
-Let's try combining this subclass with our existing code:
-
-.. code-block:: python
-
- import yarl
- import twitchio
- from typing import Annotated
- from twitchio.ext import commands
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- if isinstance(error, commands.CommandNotFound):
- return
-
- elif isinstance(error, commands.ArgumentParsingFailed):
- await context.send(error.message)
-
- else:
- print(error)
-
- bot = MyBot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- def youtube_converter(ctx: commands.Context, arg: str) -> yarl.URL:
- url = yarl.URL(arg) # this will raise if its an invalid URL.
- if url.host not in ("youtube.com", "youtu.be"):
- raise RuntimeError("Not a youtube link!")
-
- return url
-
- @bot.command(name="share")
- async def share_command(
- ctx: commands.Context,
- url: Annotated[yarl.URL, youtube_converter],
- hype: int,
- *,
- comment: str
- ) -> None:
- hype_level = "hype" if 0 < hype < 5 else "very hype"
- await ctx.send(f"{ctx.author.name} wants to share a {hype_level} link on {url.host}: {comment}")
-
- bot.run()
-
-Now, let's pass it some bad arguments and see what happens.
-
-.. image:: /images/commands_errors_1.png
-
-Now, that isn't very user intuitive, but for the purpose of this walkthrough, it'll do just fine. You can tweak that as you want!
-Let's fill this out with some more common errors:
-
-.. code-block:: python
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- if isinstance(error, commands.CommandNotFound):
- return
-
- elif isinstance(error, commands.ArgumentParsingFailed):
- await context.send(error.message)
-
- elif isinstance(error, commands.MissingRequiredArgument):
- await context.send("You're missing an argument: " + error.name)
-
- elif isinstance(error, commands.CheckFailure): # we'll explain checks later, but lets include it for now.
- await context.send("Sorry, you cant run that command: " + error.args[0])
-
- else:
- print(error)
-
-Now when we run our code we get some actual errors in our chat!
-
-.. image:: /images/commands_errors_2.png
-
-To create your own errors to handle here from arguments, subclass :class:`BadArgument` and raise that custom exception in your argument parser.
-If you want to raise errors from your commands, subclass :class:`TwitchCommandError` instead. As an example, let's change the youtube converter to use a custom error:
-
- .. code-block:: python
-
- import yarl
- import twitchio
- from typing import Annotated
- from twitchio.ext import commands
-
- class MyBot(commands.Bot):
- async def event_command_error(self, context: commands.Context, error: Exception):
- if isinstance(error, commands.CommandNotFound):
- return
-
- elif isinstance(error, commands.ArgumentParsingFailed):
- await context.send(error.message)
-
- elif isinstance(error, commands.MissingRequiredArgument):
- await context.send("You're missing an argument: " + error.name)
-
- elif isinstance(error, commands.CheckFailure): # we'll explain checks later, but lets include it for now.
- await context.send("Sorry, you cant run that command: " + error.args[0])
-
- elif isinstance(error, YoutubeConverterError):
- await context.send(f"{error.link} is not a valid youtube URL!")
-
- else:
- print(error)
-
- bot = MyBot(token="...", prefix="!", initial_channels=["iamtomahawkx", "chillymosh", "mystypy"])
-
- class YoutubeConverterError(commands.BadArgument):
- def __init__(self, link: yarl.URL):
- self.link = link
- super().__init__("Bad link!")
-
- def youtube_converter(ctx: commands.Context, arg: str) -> yarl.URL:
- url = yarl.URL(arg) # this will raise if its an invalid URL.
- if url.host not in ("youtube.com", "youtu.be"):
- raise YoutubeConverterError(url)
-
- return url
-
- @bot.command(name="share")
- async def share_command(
- ctx: commands.Context,
- url: Annotated[yarl.URL, youtube_converter],
- hype: int,
- *,
- comment: str
- ) -> None:
- hype_level = "hype" if 0 < hype < 5 else "very hype"
- await ctx.send(f"{ctx.author.name} wants to share a {hype_level} link on {url.host}: {comment}")
-
- bot.run()
-
-Now, let's pass a bad URL to it:
-
-.. image:: /images/commands_errors_3.png
-
-Great, we get our custom error! That's our basic error handling, anything more complex is beyond this walkthrough.
-
-.. tip::
-
- Many Twitchio errors have additional context contained within them.
- If you wish to build your own error messages instead of the defaults, try checking the error's attributes.
-
-___
-
-
-API Reference
---------------
-
-Bot
-++++
-.. attributetable:: Bot
-
-.. autoclass:: Bot
- :members:
- :inherited-members:
-
-.. _context_ref:
-
-Context
-++++++++
-.. attributetable:: Context
-
-.. autoclass:: Context
- :members:
- :inherited-members:
-
-Command
-++++++++
-.. attributetable:: Command
-
-.. autoclass:: Command
- :members:
- :inherited-members:
-
-Cog
-++++
-.. attributetable:: Cog
-
-.. autoclass:: Cog
- :members:
- :inherited-members:
-
-
-Cooldowns
-++++++++++
-.. autoclass:: Bucket
- :members:
-
-.. autoclass:: Cooldown
- :members:
- :inherited-members:
diff --git a/docs/exts/commands/bot.rst b/docs/exts/commands/bot.rst
new file mode 100644
index 00000000..4d15062a
--- /dev/null
+++ b/docs/exts/commands/bot.rst
@@ -0,0 +1,20 @@
+.. currentmodule:: twitchio
+
+Bot
+###
+
+.. attributetable:: twitchio.ext.commands.Bot
+
+.. autoclass:: twitchio.ext.commands.Bot
+ :members:
+ :inherited-members:
+
+
+Mixin
+-----
+
+.. attributetable:: twitchio.ext.commands.Mixin
+
+.. autoclass:: twitchio.ext.commands.Mixin
+ :members:
+ :inherited-members:
\ No newline at end of file
diff --git a/docs/exts/commands/components.rst b/docs/exts/commands/components.rst
new file mode 100644
index 00000000..a4bd443e
--- /dev/null
+++ b/docs/exts/commands/components.rst
@@ -0,0 +1,9 @@
+.. currentmodule:: twitchio
+
+Components
+##########
+
+.. attributetable:: twitchio.ext.commands.Component
+
+.. autoclass:: twitchio.ext.commands.Component
+ :members:
\ No newline at end of file
diff --git a/docs/exts/commands/core.rst b/docs/exts/commands/core.rst
new file mode 100644
index 00000000..220684d2
--- /dev/null
+++ b/docs/exts/commands/core.rst
@@ -0,0 +1,61 @@
+.. currentmodule:: twitchio
+
+Commands
+########
+
+.. attributetable:: twitchio.ext.commands.Command
+
+.. autoclass:: twitchio.ext.commands.Command
+ :members:
+
+.. attributetable:: twitchio.ext.commands.Group
+
+.. autoclass:: twitchio.ext.commands.Group
+ :members:
+
+.. attributetable:: twitchio.ext.commands.Context
+
+.. autoclass:: twitchio.ext.commands.Context
+ :members:
+
+
+Decorators
+##########
+
+.. autofunction:: twitchio.ext.commands.command
+
+.. autofunction:: twitchio.ext.commands.group
+
+.. autofunction:: twitchio.ext.commands.cooldown(*, base: BaseCooldown, rate: int, per: float, key: Callable[[Any], Hashable] | Callable[[Any], Coroutine[Any, Any, Hashable]] | BucketType, **kwargs: ~typing.Any)
+
+
+Guards
+######
+
+.. autofunction:: twitchio.ext.commands.guard
+
+.. autofunction:: twitchio.ext.commands.is_owner
+
+.. autofunction:: twitchio.ext.commands.is_staff
+
+.. autofunction:: twitchio.ext.commands.is_broadcaster
+
+.. autofunction:: twitchio.ext.commands.is_moderator
+
+.. autofunction:: twitchio.ext.commands.is_vip
+
+.. autofunction:: twitchio.ext.commands.is_elevated
+
+
+Cooldowns
+#########
+
+.. autoclass:: twitchio.ext.commands.BaseCooldown
+ :members:
+
+.. autoclass:: twitchio.ext.commands.Cooldown
+
+.. autoclass:: twitchio.ext.commands.GCRACooldown
+
+.. attributetable:: twitchio.ext.commands.BucketType()
+.. autoclass:: twitchio.ext.commands.BucketType()
diff --git a/docs/exts/commands/exceptions.rst b/docs/exts/commands/exceptions.rst
new file mode 100644
index 00000000..779202d8
--- /dev/null
+++ b/docs/exts/commands/exceptions.rst
@@ -0,0 +1,87 @@
+.. currentmodule:: twitchio.ext.commands
+
+Exceptions
+##########
+
+Payloads
+--------
+
+.. attributetable:: twitchio.ext.commands.CommandErrorPayload
+
+.. autoclass:: twitchio.ext.commands.CommandErrorPayload
+ :members:
+
+
+Exceptions
+----------
+
+.. autoexception:: twitchio.ext.commands.CommandError
+
+.. autoexception:: twitchio.ext.commands.ComponentLoadError
+
+.. autoexception:: twitchio.ext.commands.CommandInvokeError
+
+.. autoexception:: twitchio.ext.commands.CommandHookError
+
+.. autoexception:: twitchio.ext.commands.CommandNotFound
+
+.. autoexception:: twitchio.ext.commands.CommandExistsError
+
+.. autoexception:: twitchio.ext.commands.PrefixError
+
+.. autoexception:: twitchio.ext.commands.InputError
+
+.. autoexception:: twitchio.ext.commands.ArgumentError
+
+.. autoexception:: twitchio.ext.commands.ConversionError
+
+.. autoexception:: twitchio.ext.commands.BadArgument
+
+.. autoexception:: twitchio.ext.commands.MissingRequiredArgument
+
+.. autoexception:: twitchio.ext.commands.UnexpectedQuoteError
+
+.. autoexception:: twitchio.ext.commands.InvalidEndOfQuotedStringError
+
+.. autoexception:: twitchio.ext.commands.ExpectedClosingQuoteError
+
+.. autoexception:: twitchio.ext.commands.GuardFailure
+
+.. autoexception:: twitchio.ext.commands.CommandOnCooldown
+
+.. autoexception:: twitchio.ext.commands.ModuleLoadFailure
+
+.. autoexception:: twitchio.ext.commands.ModuleAlreadyLoadedError
+
+.. autoexception:: twitchio.ext.commands.ModuleNotLoadedError
+
+.. autoexception:: twitchio.ext.commands.NoEntryPointError
+
+
+Exception Hierarchy
+~~~~~~~~~~~~~~~~~~~
+
+.. exception_hierarchy::
+
+ - :exc:`CommandError`
+ - :exc:`ComponentLoadError`
+ - :exc:`CommandInvokeError`
+ - :exc:`CommandHookError`
+ - :exc:`CommandNotFound`
+ - :exc:`CommandExistsError`
+ - :exc:`PrefixError`
+ - :exc:`InputError`
+ - :exc:`ArgumentError`
+ - :exc:`ConversionError`
+ - :exc:`BadArgument`
+ - :exc:`MissingRequiredArgument`
+ - :exc:`UnexpectedQuoteError`
+ - :exc:`InvalidEndOfQuotedStringError`
+ - :exc:`ExpectedClosingQuoteError`
+ - :exc:`GuardFailure`
+ - :exc:`CommandOnCooldown`
+ - :exc:`ModuleError`
+ - :exc:`ModuleLoadFailure`
+ - :exc:`ModuleAlreadyLoadedError`
+ - :exc:`ModuleNotLoadedError`
+ - :exc:`NoEntryPointError`
\ No newline at end of file
diff --git a/docs/exts/commands/index.rst b/docs/exts/commands/index.rst
new file mode 100644
index 00000000..bff0c98d
--- /dev/null
+++ b/docs/exts/commands/index.rst
@@ -0,0 +1,10 @@
+Commands
+########
+
+.. toctree::
+ :maxdepth: 2
+
+ bot
+ components
+ core
+ exceptions
\ No newline at end of file
diff --git a/docs/exts/eventsub.rst b/docs/exts/eventsub.rst
deleted file mode 100644
index 6335712d..00000000
--- a/docs/exts/eventsub.rst
+++ /dev/null
@@ -1,566 +0,0 @@
-.. currentmodule:: twitchio.ext.eventsub
-
-.. _eventsub_ref:
-
-EventSub Ext
-=============
-
-The EventSub ext is made to receive eventsub webhook notifications from twitch.
-For those not familiar with eventsub, it allows you to subscribe to certain events, and when these events happen,
-Twitch will send you an HTTP request containing information on the event. This ext abstracts away the complex portions of this,
-integrating seamlessly into the twitchio Client event dispatching system.
-
-.. warning::
- This ext requires you to have a public facing ip AND domain, and to be able to receive inbound requests.
-
-.. note::
- Twitch requires EventSub targets to have TLS/SSL enabled (https). TwitchIO does not support this, as such you should
- use a reverse proxy such as ``nginx`` to handle TLS/SSL.
-
-
-A Quick Example
-----------------
-
-.. code-block:: python3
-
- import twitchio
- from twitchio.ext import eventsub, commands
- bot = commands.Bot(token="...")
- eventsub_client = eventsub.EventSubClient(bot, "some_secret_string", "https://your-url.here/callback")
- # when subscribing (you can only await inside coroutines)
-
- await eventsub_client.subscribe_channel_subscriptions(channel_ID)
-
- @bot.event()
- async def eventsub_notification_subscription(payload: eventsub.ChannelSubscribeData):
- ...
-
- bot.loop.create_task(eventsub_client.listen(port=4000))
- bot.loop.create_task(bot.start())
- bot.loop.run_forever()
-
-
-Running Eventsub Inside a Commands Bot
----------------------------------------
-
-.. code-block:: python3
-
- import twitchio
- from twitchio.ext import commands, eventsub
-
- esbot = commands.Bot.from_client_credentials(client_id='...',
- client_secret='...')
- esclient = eventsub.EventSubClient(esbot,
- webhook_secret='...',
- callback_route='https://your-url.here/callback')
-
-
- class Bot(commands.Bot):
-
- def __init__(self):
- super().__init__(token='...', prefix='!', initial_channels=['channel'])
-
- async def __ainit__(self) -> None:
- self.loop.create_task(esclient.listen(port=4000))
-
- try:
- await esclient.subscribe_channel_follows_v2(broadcaster=some_channel_ID, moderator=a_channel_mod_ID)
- except twitchio.HTTPException:
- pass
-
- async def event_ready(self):
- print('Bot is ready!')
-
-
- bot = Bot()
- bot.loop.run_until_complete(bot.__ainit__())
-
-
- @esbot.event()
- async def event_eventsub_notification_followV2(payload: eventsub.ChannelFollowData) -> None:
- print('Received event!')
- channel = bot.get_channel('channel')
- await channel.send(f'{payload.data.user.name} followed woohoo!')
-
- bot.run()
-
-
-Event Reference
-----------------
-This is a list of events dispatched by the eventsub ext.
-
-.. function:: event_eventsub_notification_user_authorization_grant(event: UserAuthorizationGrantedData)
-
- Called when your app has had access granted on a channel.
-
-.. function:: event_eventsub_revokation(event: RevokationEvent)
-
- Called when your app has had access revoked on a channel.
-
-.. function:: event_eventsub_webhook_callback_verification(event: ChallengeEvent)
-
- Called when Twitch sends a challenge to your server.
-
- .. note::
- You generally won't need to interact with this event. The ext will handle responding to the challenge automatically.
-
-.. function:: event_eventsub_keepalive(event: KeepaliveEvent)
-
- Called when a Twitch sends a keepalive event. You do not need to use this in daily usage.
-
- .. note::
- You generally won't need to interact with this event.
-
-.. function:: event_eventsub_reconnect(event: ReconnectEvent)
-
- Called when a Twitch wishes for us to reconnect.
-
- .. note::
- You generally won't need to interact with this event. The library will automatically handle reconnecting.
-
-.. function:: event_eventsub_notification_follow(event: ChannelFollowData)
-
- Called when someone creates a follow on a channel you've subscribed to.
-
- .. warning::
- Twitch has removed this, please use :func:`event_eventsub_notification_followV2`
-
-
-.. function:: event_eventsub_notification_followV2(event: ChannelFollowData)
-
- Called when someone creates a follow on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_subscription(event: ChannelSubscribeData)
-
- Called when someone subscribes to a channel that you've subscribed to.
-
-.. function:: event_eventsub_notification_subscription_end(event: ChannelSubscriptionEndData)
-
- Called when a subscription to a channel that you've subscribed to ends.
-
-.. function:: event_eventsub_notification_subscription_gift(event: ChannelSubscriptionGiftData)
-
- Called when someone gifts a subscription to a channel that you've subscribed to.
-
-.. function:: event_eventsub_notification_subscription_message(event: ChannelSubscriptionMessageData)
-
- Called when someone resubscribes with a message to a channel that you've subscribed to.
-
-.. function:: event_eventsub_notification_cheer(event: ChannelCheerData)
-
- Called when someone cheers on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_raid(event: ChannelRaidData)
-
- Called when someone raids a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_poll_begin(event: PollBeginProgressData)
-
- Called when a poll begins on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_poll_progress(event: PollBeginProgressData)
-
- Called repeatedly while a poll is running on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_poll_end(event: PollEndData)
-
- Called when a poll ends on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_prediction_begin(event: PredictionBeginProgressData)
-
- Called when a prediction starts on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_prediction_progress(event: PredictionBeginProgressData)
-
- Called repeatedly while a prediction is running on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_prediction_lock(event: PredictionLockData)
-
- Called when a prediction locks on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_prediction_end(event: PredictionEndData)
-
- Called when a prediction ends on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_stream_start(event: StreamOnlineData)
-
- Called when a stream starts on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_stream_end(event: StreamOfflineData)
-
- Called when a stream ends on a channel you've subscribed to.
-
-.. function:: event_eventsub_notification_channel_goal_begin(event: ChannelGoalBeginProgressData)
-
- Called when a streamer starts a goal on their channel.
-
-.. function:: event_eventsub_notification_channel_goal_progress(event: ChannelGoalBeginProgressData)
-
- Called when there is an update event to a channel's goal.
-
-.. function:: event_eventsub_notification_channel_goal_end(event: ChannelGoalEndData)
-
- Called when someone ends a goal on their channel.
-
-.. function:: event_eventsub_notification_hypetrain_begin(event: HypeTrainBeginProgressData)
-
- Called when a hype train starts on their channel.
-
-.. function:: event_eventsub_notification_hypetrain_progress(event: HypeTrainBeginProgressData)
-
- Called when a hype train receives an update on their channel.
-
-.. function:: event_eventsub_notification_hypetrain_end(event: HypeTrainEndData)
-
- Called when a hype train ends on their channel.
-
-.. function:: event_eventsub_notification_channel_shield_mode_begin(event: ChannelShieldModeBeginData)
-
- Called when a channel's Shield Mode status is activated.
-
-.. function:: event_eventsub_notification_channel_shield_mode_end(event: ChannelShieldModeEndData)
-
- Called when a channel's Shield Mode status is deactivated.
-
-.. function:: event_eventsub_notification_channel_shoutout_create(event: ChannelShoutoutCreateData)
-
- Called when a channel sends a shoutout.
-
-.. function:: event_eventsub_notification_channel_shoutout_receive(event: ChannelShoutoutReceiveData)
-
- Called when a channel receives a shoutout.
-
-.. function:: event_eventsub_notification_channel_charity_donate(event: ChannelCharityDonationData)
-
- Called when a user donates to an active charity campaign.
-
-.. function:: event_eventsub_notification_channel_ad_break_begin(event: ChannelAdBreakBeginData)
-
- Called when a user runs a midroll commercial break, either manually or automatically via ads manager.
-
-API Reference
---------------
-
-.. attributetable:: EventSubClient
-
-.. autoclass:: EventSubClient
- :members:
- :undoc-members:
-
-.. attributetable:: EventSubWSClient
-
-.. autoclass:: EventSubWSClient
- :members:
- :undoc-members:
-
-.. attributetable:: Subscription
-
-.. autoclass:: Subscription
- :members:
- :inherited-members:
-
-.. attributetable:: Headers
-
-.. autoclass:: Headers
- :members:
- :inherited-members:
-
-.. attributetable:: WebsocketHeaders
-
-.. autoclass:: WebsocketHeaders
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelBanData
-
-.. autoclass:: ChannelBanData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelShieldModeBeginData
-
-.. autoclass:: ChannelShieldModeBeginData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelShieldModeEndData
-
-.. autoclass:: ChannelShieldModeEndData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelShoutoutCreateData
-
-.. autoclass:: ChannelShoutoutCreateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelShoutoutReceiveData
-
-.. autoclass:: ChannelShoutoutReceiveData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelSubscribeData
-
-.. autoclass:: ChannelSubscribeData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelSubscriptionGiftData
-
-.. autoclass:: ChannelSubscriptionGiftData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelSubscriptionMessageData
-
-.. autoclass:: ChannelSubscriptionMessageData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelCheerData
-
-.. autoclass:: ChannelCheerData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelUpdateData
-
-.. autoclass:: ChannelUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelFollowData
-
-.. autoclass:: ChannelFollowData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelRaidData
-
-.. autoclass:: ChannelRaidData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelModeratorAddRemoveData
-
-.. autoclass:: ChannelModeratorAddRemoveData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelGoalBeginProgressData
-
-.. autoclass:: ChannelGoalBeginProgressData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelGoalEndData
-
-.. autoclass:: ChannelGoalEndData
- :members:
- :inherited-members:
-
-.. attributetable::: CustomReward
-
-.. autoclass:: CustomReward
- :members:
- :inherited-members:
-
-.. attributetable::: CustomRewardAddUpdateRemoveData
-
-.. autoclass:: CustomRewardAddUpdateRemoveData
- :members:
- :inherited-members:
-
-.. attributetable::: CustomRewardRedemptionAddUpdateData
-
-.. autoclass:: CustomRewardRedemptionAddUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: HypeTrainContributor
-
-.. autoclass:: HypeTrainContributor
- :members:
- :inherited-members:
-
-.. attributetable::: HypeTrainBeginProgressData
-
-.. autoclass:: HypeTrainBeginProgressData
- :members:
- :inherited-members:
-
-.. attributetable::: HypeTrainEndData
-
-.. autoclass:: HypeTrainEndData
- :members:
- :inherited-members:
-
-.. attributetable::: PollChoice
-
-.. autoclass:: PollChoice
- :members:
- :inherited-members:
-
-.. attributetable::: BitsVoting
-
-.. autoclass:: BitsVoting
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelPointsVoting
-
-.. autoclass:: ChannelPointsVoting
- :members:
- :inherited-members:
-
-.. attributetable::: PollStatus
-
-.. autoclass:: PollStatus
- :members:
- :inherited-members:
-
-.. attributetable::: PollBeginProgressData
-
-.. autoclass:: PollBeginProgressData
- :members:
- :inherited-members:
-
-.. attributetable::: PollEndData
-
-.. autoclass:: PollEndData
- :members:
- :inherited-members:
-
-.. attributetable::: Predictor
-
-.. autoclass:: Predictor
- :members:
- :inherited-members:
-
-.. attributetable::: PredictionOutcome
-
-.. autoclass:: PredictionOutcome
- :members:
- :inherited-members:
-
-.. attributetable::: PredictionStatus
-
-.. autoclass:: PredictionStatus
- :members:
- :inherited-members:
-
-.. attributetable::: PredictionBeginProgressData
-
-.. autoclass:: PredictionBeginProgressData
- :members:
- :inherited-members:
-
-.. attributetable::: PredictionLockData
-
-.. autoclass:: PredictionLockData
- :members:
- :inherited-members:
-
-.. attributetable::: PredictionEndData
-
-.. autoclass:: PredictionEndData
- :members:
- :inherited-members:
-
-.. attributetable::: StreamOnlineData
-
-.. autoclass:: StreamOnlineData
- :members:
- :inherited-members:
-
-.. attributetable::: StreamOfflineData
-
-.. autoclass:: StreamOfflineData
- :members:
- :inherited-members:
-
-.. attributetable::: UserAuthorizationRevokedData
-
-.. autoclass:: UserAuthorizationRevokedData
- :members:
- :inherited-members:
-
-.. attributetable::: UserUpdateData
-
-.. autoclass:: UserUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelCharityDonationData
-
-.. autoclass:: ChannelCharityDonationData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelUnbanRequestCreateData
-
-.. autoclass:: ChannelUnbanRequestCreateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelUnbanRequestResolveData
-
-.. autoclass:: ChannelUnbanRequestResolveData
- :members:
- :inherited-members:
-
-.. attributetable::: AutomodMessageHoldData
-
-.. autoclass:: AutomodMessageHoldData
- :members:
- :inherited-members:
-
-.. attributetable::: AutomodMessageUpdateData
-
-.. autoclass:: AutomodMessageUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: AutomodSettingsUpdateData
-
-.. autoclass:: AutomodSettingsUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: AutomodTermsUpdateData
-
-.. autoclass:: AutomodTermsUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: SuspiciousUserUpdateData
-
-.. autoclass:: SuspiciousUserUpdateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelModerateData
-
-.. autoclass:: ChannelModerateData
- :members:
- :inherited-members:
-
-.. attributetable::: ChannelAdBreakBeginData
-
-.. autoclass:: ChannelAdBreakBeginData
- :members:
- :inherited-members:
-
-.. autoclass:: ChannelVIPAddRemove
- :members:
- :inherited-members:
-
-.. autoclass:: AutoCustomReward
- :members:
- :inherited-members:
-
-.. autoclass:: AutoRewardRedeem
- :members:
- :inherited-members:
diff --git a/docs/exts/pubsub.rst b/docs/exts/pubsub.rst
deleted file mode 100644
index 60d6b347..00000000
--- a/docs/exts/pubsub.rst
+++ /dev/null
@@ -1,310 +0,0 @@
-.. currentmodule:: twitchio.ext.pubsub
-
-.. _pubsub-ref:
-
-PubSub Ext
-===========
-
-The PubSub Ext is designed to make receiving events from twitch's PubSub websocket simple.
-This ext handles all the necessary connection management, authorizing, and dispatching events through
-TwitchIO's Client event system.
-
-A quick example
-----------------
-
-.. code-block:: python3
-
- import twitchio
- import asyncio
- from twitchio.ext import pubsub
-
- my_token = "..."
- users_oauth_token = "..."
- users_channel_id = 12345
- client = twitchio.Client(token=my_token)
- client.pubsub = pubsub.PubSubPool(client)
-
- @client.event()
- async def event_pubsub_bits(event: pubsub.PubSubBitsMessage):
- pass # do stuff on bit redemptions
-
- @client.event()
- async def event_pubsub_channel_points(event: pubsub.PubSubChannelPointsMessage):
- pass # do stuff on channel point redemptions
-
- async def main():
- topics = [
- pubsub.channel_points(users_oauth_token)[users_channel_id],
- pubsub.bits(users_oauth_token)[users_channel_id]
- ]
- await client.pubsub.subscribe_topics(topics)
- await client.start()
-
- client.loop.run_until_complete(main())
-
-This will connect to to the pubsub server, and subscribe to the channel points and bits events
-for user 12345, using the oauth token they have given us with the corresponding scopes.
-
-Topics
--------
-
-Each of the topics below needs to first be called with a user oauth token, and then needs channel id(s) passed to it, as such:
-
-.. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- topic = pubsub.bits(user_token)[user_channel_id]
-
-If the topic requires multiple channel ids, they should be passed as such:
-
-.. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345 # the channel to listen to
- mods_channel_id = 67890 # the mod to listen for actions from
- topic = pubsub.moderation_user_action(user_token)[user_channel_id][mods_channel_id]
-
-
-.. function:: bits(oauth_token: str)
-
- This topic listens for bit redemptions on the given channel.
- This topic dispatches the ``pubsub_bits`` client event.
- This topic takes one channel id, the channel to listen on, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- topic = pubsub.bits(user_token)[user_channel_id]
-
- This can be received via the following:
-
- .. code-block:: python3
-
- import twitchio
- from twitchio.ext import pubsub
-
- client = twitchio.Client(token="...")
-
- @client.event()
- async def event_pubsub_bits(event: pubsub.PubSubBitsMessage):
- ...
-
-
-.. function:: bits_badge(oauth_token: str)
-
- This topic listens for bit badge upgrades on the given channel.
- This topic dispatches the ``pubsub_bits_badge`` client event.
- This topic takes one channel id, the channel to listen on, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- topic = pubsub.bits_badge(user_token)[user_channel_id]
-
- This can be received via the following:
-
- .. code-block:: python3
-
- import twitchio
- from twitchio.ext import pubsub
-
- client = twitchio.Client(token="...")
-
- @client.event()
- async def event_pubsub_bits_badge(event: pubsub.PubSubBitsBadgeMessage):
- ...
-
-
-.. function:: channel_points(oauth_token: str)
-
- This topic listens for channel point redemptions on the given channel.
- This topic dispatches the ``pubsub_channel_points`` client event.
- This topic takes one channel id, the channel to listen on, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- topic = pubsub.channel_points(user_token)[user_channel_id]
-
- This can be received via the following:
-
- .. code-block:: python3
-
- import twitchio
- from twitchio.ext import pubsub
-
- client = twitchio.Client(token="...")
-
- @client.event()
- async def event_pubsub_channel_points(event: pubsub.PubSubChannelPointsMessage):
- ...
-
-
-.. function:: channel_subscriptions(oauth_token: str)
-
- This topic listens for subscriptions on the given channel.
- This topic dispatches the ``pubsub_subscription`` client event.
- This topic takes one channel id, the channel to listen on, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- topic = pubsub.channel_subscriptions(user_token)[user_channel_id]
-
-
-.. function:: moderation_user_action(oauth_token: str)
-
- This topic listens for moderation actions on the given channel.
- This topic dispatches the ``pubsub_moderation`` client event.
- This topic takes two channel ids, the channel to listen on, and the user to listen to, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- user_channel_id = 12345
- moderator_id = 67890
- topic = pubsub.bits_badge(user_token)[user_channel_id][moderator_id]
-
- This event can receive many different events; :class:`PubSubModerationActionBanRequest`,
- :class:`PubSubModerationActionChannelTerms`, :class:`PubSubModerationActionModeratorAdd`, or
- :class:`PubSubModerationAction`
-
- It can be received via the following:
-
- .. code-block:: python3
-
- import twitchio
- from twitchio.ext import pubsub
-
- client = twitchio.Client(token="...")
-
- @client.event()
- async def event_pubsub_moderation(event):
- ...
-
-
-.. function:: whispers(oauth_token: str)
-
- .. warning::
-
- This does not have a model created yet, and will error when a whisper event is received
-
- This topic listens for bit badge upgrades on the given channel.
- This topic dispatches the `pubsub_whisper` client event.
- This topic takes one channel id, the channel to listen to whispers from, e.g.:
-
- .. code-block:: python3
-
- from twitchio.ext import pubsub
- user_token = "..."
- listen_to_id = 12345
- topic = pubsub.whispers(user_token)[listen_to_id]
-
-Hooks
-------
-
-There are two hooks available in the PubSubPool class. To access these hooks, subclass the PubSubPool.
-After subclassing, use the subclass like normal.
-
-The ``auth_fail_hook`` is called whenever you attempt to subscribe to a topic and the auth token is invalid.
-From the hook, you are able to fix your token (maybe you need to prompt the user for a new token), and then subscribe again.
-
-The ``reconnect_hook`` is called whenevever a node has to reconnect to twitch, for any reason. The node will wait for you to
-return a list of topics before reconnecting. Any modifications to the topics will be applied to the node.
-
-.. code-block:: python3
-
- from typing import List
- from twitchio.ext import pubsub
-
- class MyPool(pubsub.PubSubPool):
- async def auth_fail_hook(self, topics: List[pubsub.Topic]) -> None:
- fixed_topics = fix_my_auth_tokens(topics) # somehow fix your auth tokens
- await self.subscribe_topics(topics)
-
- async def reconnect_hook(self, node: pubsub.PubSubWebsocket, topics: List[pubsub.Topic]) -> List[pubsub.Topic]:
- return topics
-
-
-Api Reference
---------------
-
-.. attributetable:: Topic
-
-.. autoclass:: Topic
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubPool
-
-.. autoclass:: PubSubPool
- :members:
-
-.. attributetable:: PubSubChatMessage
-
-.. autoclass:: PubSubChatMessage
- :members:
-
-.. attributetable:: PubSubBadgeEntitlement
-
-.. autoclass:: PubSubBadgeEntitlement
- :members:
-
-.. attributetable:: PubSubMessage
-
-.. autoclass:: PubSubMessage
- :members:
-
-.. attributetable:: PubSubBitsMessage
-
-.. autoclass:: PubSubBitsMessage
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubBitsBadgeMessage
-
-.. autoclass:: PubSubBitsBadgeMessage
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubChannelPointsMessage
-
-.. autoclass:: PubSubChannelPointsMessage
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubModerationAction
-
-.. autoclass:: PubSubModerationAction
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubModerationActionBanRequest
-
-.. autoclass:: PubSubModerationActionBanRequest
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubModerationActionChannelTerms
-
-.. autoclass:: PubSubModerationActionChannelTerms
- :members:
- :inherited-members:
-
-.. attributetable:: PubSubModerationActionModeratorAdd
-
-.. autoclass:: PubSubModerationActionModeratorAdd
- :members:
- :inherited-members:
diff --git a/docs/exts/routines.rst b/docs/exts/routines.rst
deleted file mode 100644
index 451ccda2..00000000
--- a/docs/exts/routines.rst
+++ /dev/null
@@ -1,117 +0,0 @@
-.. currentmodule:: twitchio.ext.routines
-
-.. _routines-ref:
-
-
-Routines Ext
-===========================
-
-Routines are helpers designed to make running async background tasks in TwitchIO easier.
-Overall Routines are a QoL and are designed to be simple and easy to use.
-
-Recipes
----------------------------
-
-**A simple routine:**
-
-This routine will run every 5 seconds for 5 iterations.
-
-.. code-block:: python3
-
- from twitchio.ext import routines
-
-
- @routines.routine(seconds=5.0, iterations=5)
- async def hello(arg: str):
- print(f'Hello {arg}!')
-
-
- hello.start('World')
-
-
-**Routine with a before/after_routine hook:**
-
-This routine will run a hook before starting, this can be useful for setting up state before the routine runs.
-The `before_routine` hook will only be called once. Similarly `after_routine` will be called once at the end of the
-routine.
-
-.. code-block:: python3
-
- from twitchio.ext import routines
-
-
- @routines.routine(hours=1)
- async def hello():
- print('Hello World!')
-
- @hello.before_routine
- async def hello_before():
- print('I am run first!')
-
-
- hello.start()
-
-
-**Routine with an error handler:**
-
-This example shows a routine with a non-default error handler; by default all routines will stop on error.
-You can change this behaviour by adding `stop_on_error=False` to your routine start function.
-
-
-.. code-block:: python3
-
- from twitchio.ext import routines
-
-
- @routines.routine(minutes=10)
- async def hello(arg: str):
- raise RuntimeError
-
- @hello.error
- async def hello_on_error(error: Exception):
- print(f'Hello routine raised: {error}.')
-
-
- hello.start('World', stop_on_error=True)
-
-
-**Routine which runs at a specific time:**
-
-This routine will run at the same time everyday.
-If a naive datetime is provided, your system local time is used.
-
-The below example shows a routine which will first be ran on the **1st, June 2021 at 9:30am** system local time.
-It will then be ran every 24 hours after the initial date, until stopped.
-
-
-If the **date** has already passed, the routine will run at the next specified time.
-For example: If today was the **2nd, June 2021 8:30am** and your datetime was scheduled to run on the
-**1st, June 2021 at 9:30am**, your routine will first run on **2nd, June 2021 at 9:30am**.
-
-In simpler terms, datetimes in the past only care about the time, not the date. This can be useful when scheduling
-routines that don't need to be started on a specific date.
-
-
-.. code-block:: python3
-
- import datetime
-
- from twitchio.ext import routines
-
-
- @routines.routine(time=datetime.datetime(year=2021, month=6, day=1, hour=9, minute=30))
- async def hello(arg: str):
- print(f'Hello {arg}!')
-
-
- hello.start('World')
-
-API Reference
----------------------------
-
-.. attributetable:: Routine
-
-.. autoclass:: Routine
- :members:
-
-.. autofunction:: twitchio.ext.routines.routine
diff --git a/docs/exts/routines/index.rst b/docs/exts/routines/index.rst
new file mode 100644
index 00000000..077c2d83
--- /dev/null
+++ b/docs/exts/routines/index.rst
@@ -0,0 +1,9 @@
+Routines
+########
+
+.. attributetable:: twitchio.ext.routines.Routine
+
+.. autoclass:: twitchio.ext.routines.Routine
+ :members:
+
+.. autofunction:: twitchio.ext.routines.routine
\ No newline at end of file
diff --git a/docs/exts/sounds.rst b/docs/exts/sounds.rst
deleted file mode 100644
index eb5b9a14..00000000
--- a/docs/exts/sounds.rst
+++ /dev/null
@@ -1,183 +0,0 @@
-.. currentmodule:: twitchio.ext.sounds
-
-.. _sounds-ref:
-
-
-Sounds Ext
-===========================
-
-Sounds is an extension to easily play sounds on your local machine plugged directly into your bot.
-Sounds is currently a Beta release, and as such should be treated so.
-
-Currently sounds supports local files and YouTube searches. See below for more details.
-
-Sounds requires a few extra steps to get started, below is a short guide on how to get started with sounds:
-
-**Installation:**
-
-**1 -** First install TwitchIO with the following commands:
-
-**Windows:**
-
-.. code:: sh
-
- py -3.9 -m pip install -U twitchio[sounds]
-
-**Linux:**
-
-.. code:: sh
-
- python3.9 -m pip install -U twitchio[sounds]
-
-
-If you are on Linux you can skip to step **3**.
-
-
-**2 -** Windows users require an extra step to get sounds working, in your console run the following commands:
-
-.. code:: sh
-
- py -3.9 -m pip install -U pipwin
-
-
-Then:
-
-.. code:: sh
-
- pipwin install pyaudio
-
-
-**3 -** If you are on windows, download ffmpeg and make sure you add it your path. You can find the .exe required in
-the /bin folder. Alternatively copy and paste ffmpeg.exe into your bots Working Directory.
-
-Linux/MacOS users should use their package manager to download and install ffmpeg on their system.
-
-Recipes
----------------------------
-
-**A simple Bot with an AudioPlayer:**
-
-This bot will search YouTube for a relevant video and playback its audio.
-
-.. code-block:: python3
-
- from twitchio.ext import commands, sounds
-
-
- class Bot(commands.Bot):
-
- def __init__(self):
- super().__init__(token='...', prefix='!', initial_channels=['...'])
-
- self.player = sounds.AudioPlayer(callback=self.player_done)
-
- async def event_ready(self) -> None:
- print('Successfully logged in!')
-
- async def player_done(self):
- print('Finished playing song!')
-
- @commands.command()
- async def play(self, ctx: commands.Context, *, search: str) -> None:
- track = await sounds.Sound.ytdl_search(search)
- self.player.play(track)
-
- await ctx.send(f'Now playing: {track.title}')
-
-
- bot = Bot()
- bot.run()
-
-
-**Sound with a Local File:**
-
-This Sound will target a local file on your machine. Just pass the location to source.
-
-.. code-block:: python3
-
- sound = sounds.Sound(source='my_audio.mp3')
-
-
-**Multiple Players:**
-
-This example shows how to setup multiple players. Useful for playing music in addition to sounds on events!
-
-
-.. code-block:: python3
-
- import twitchio
- from twitchio.ext import commands, sounds
-
-
- class Bot(commands.Bot):
-
- def __init__(self):
- super().__init__(token='...', prefix='!', initial_channels=['...'])
-
- self.music_player = sounds.AudioPlayer(callback=self.music_done)
- self.event_player = sounds.AudioPlayer(callback=self.sound_done)
-
- async def event_ready(self) -> None:
- print('Successfully logged in!')
-
- async def music_done(self):
- print('Finished playing song!')
-
- async def sound_done(self):
- print('Finished playing sound!')
-
- @commands.command()
- async def play(self, ctx: commands.Context, *, search: str) -> None:
- track = await sounds.Sound.ytdl_search(search)
- self.music_player.play(track)
-
- await ctx.send(f'Now playing: {track.title}')
-
- async def event_message(self, message: twitchio.Message) -> None:
- # This is just an example only...
- # Playing a sound on every message could get extremely spammy...
- sound = sounds.Sound(source='beep.mp3')
- self.event_player.play(sound)
-
-
- bot = Bot()
- bot.run()
-
-
-**Common AudioPlayer actions:**
-
-.. code-block:: python3
-
- # Set the volume of the player...
- player.volume = 50
-
- # Pause the player...
- player.pause()
-
- # Resume the player...
- player.resume()
-
- # Stop the player...
- player.stop()
-
- # Check if the player is playing...
- player.is_playing
-
-
-API Reference
----------------------------
-
-.. attributetable:: OutputDevice
-
-.. autoclass:: OutputDevice
- :members:
-
-.. attributetable:: Sound
-
-.. autoclass:: Sound
- :members:
-
-.. attributetable:: AudioPlayer
-
-.. autoclass:: AudioPlayer
- :members:
diff --git a/docs/exts/sounds/index.rst b/docs/exts/sounds/index.rst
new file mode 100644
index 00000000..5ec0b1fa
--- /dev/null
+++ b/docs/exts/sounds/index.rst
@@ -0,0 +1,6 @@
+Sounds
+######
+
+.. important::
+
+ This feature has not yet been implemented.
\ No newline at end of file
diff --git a/docs/faq.rst b/docs/faq.rst
deleted file mode 100644
index 1d615598..00000000
--- a/docs/faq.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-:orphan:
-
-Frequently Asked Questions
-==================================
-Frequently asked questions for TwitchIO 2.
-
-.. rst-class:: this-will-duplicate-information-and-it-is-still-useful-here
-.. contents:: Questions
- :local:
-
-
-How can I run something on a schedule in the background?
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-TwitchIO has a helper extension named routines. Routines are asyncio tasks that run on a schedule in the background.
-Consider reading through the :doc:`exts/routines` documentation.
-
-How can I send a message?
-~~~~~~~~~~~~~~~~~~~~~~~~~
-TwitchIO is an object orientated library with stateful objects. To send a message simply use ``await .send('Hello World!)``
-on any ``Messageable`` class. E.g :class:`twitchio.PartialChatter`, :class:`twitchio.Channel` or :class:`twitchio.ext.commands.Context`.
diff --git a/docs/changelog.rst b/docs/getting-started/changelog.rst
similarity index 98%
rename from docs/changelog.rst
rename to docs/getting-started/changelog.rst
index 5c39e093..85c0ef86 100644
--- a/docs/changelog.rst
+++ b/docs/getting-started/changelog.rst
@@ -1,5 +1,15 @@
:orphan:
+.. _changes:
+
+
+Changelog
+##########
+
+3.0.0b
+======
+
+The changelog for this version is too large to display. Please see :ref:`Migrating Guide` for more information.
2.10.0
=======
@@ -111,6 +121,7 @@
- :class:`~twitchio.ChannelFollowerEvent`
- :class:`~twitchio.ChannelFollowingEvent`
- New optional ``is_featured`` query parameter for :func:`~twitchio.PartialUser.fetch_clips`
+ - New optional ``is_featured`` query parameter for :func:`~twitchio.PartialUser.fetch_clips`
- New attribute :attr:`~twitchio.Clip.is_featured` for :class:`~twitchio.Clip`
- Bug fixes
@@ -149,6 +160,7 @@
- Added :attr:`~twitchio.ChannelInfo.content_classification_labels` and :attr:`~twitchio.ChannelInfo.is_branded_content` to :class:`~twitchio.ChannelInfo`
- Added new parameters to :func:`~twitchio.PartialUser.modify_stream` for ``is_branded_content`` and ``content_classification_labels``
+
- Bug fixes
- Fix :func:`~twitchio.Client.search_categories` due to :attr:`~twitchio.Game.igdb_id` being added to :class:`~twitchio.Game`
- Made Chatter :attr:`~twitchio.Chatter.id` property public
@@ -156,6 +168,7 @@
- Fix reconnect loop when Twitch sends a RECONNECT via IRC websocket
- Fix :func:`~twitchio.CustomReward.edit` so it now can enable the reward
+
- Other Changes
- Updated the HTTPException to provide useful information when an error is raised.
@@ -395,7 +408,7 @@ Massive documentation updates
2.2.0
=====
- ext.sounds
- - Added sounds extension. Check the :ref:`sounds-ref` documentation for more information.
+ - Added sounds extension.
- TwitchIO
- Loosen aiohttp requirements to allow 3.8.1
@@ -516,4 +529,4 @@ New logo!
- ext.eventsub
- fix :class:`ext.eventsub.models.ChannelBanData`'s ``permanent`` attribute accessing nonexistent attrs from the event payload
- - Add documentation
+ - Add documentation
\ No newline at end of file
diff --git a/docs/getting-started/faq.rst b/docs/getting-started/faq.rst
new file mode 100644
index 00000000..785db96d
--- /dev/null
+++ b/docs/getting-started/faq.rst
@@ -0,0 +1,30 @@
+.. _faqs:
+
+FAQ
+###
+
+
+Frequently asked Questions
+--------------------------
+
+
+How do I send a message to a specific channel?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To send a message to a specific channel/broadcaster you should use :meth:`~twitchio.PartialUser.send_message` on
+:class:`~twitchio.PartialUser`. You can create a :class:`~twitchio.PartialUser` with only the users ID with
+:meth:`~twitchio.Client.create_partialuser` on :class:`~twitchio.Client` or :class:`~twitchio.ext.commands.Bot`.
+
+.. code:: python3
+
+ user = bot.create_partialuser(id="...")
+ await user.send_message(sender=bot.user, message="Hello World!")
+
+If you are inside of a :class:`~twitchio.ext.commands.Command`,
+consider using the available :class:`~twitchio.ext.commands.Context` instead.
+
+
+.. _irc_faq:
+
+Why was IRC functionality removed from the core library?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/getting-started/installing.rst b/docs/getting-started/installing.rst
new file mode 100644
index 00000000..e7023a50
--- /dev/null
+++ b/docs/getting-started/installing.rst
@@ -0,0 +1,160 @@
+.. _installing:
+
+Installing
+##########
+
+
+TwitchIO 3 currently supports the following `Python `_ versions:
+
+
++-----------------------------+-------------------------------------------------------+-------------------------------+
+| Python Version | Status | Notes |
++=============================+=======================================================+===============================+
+| **<= 3.10** | .. raw:: html | ... |
+| | | |
+| | Not Supported | |
+| | | |
++-----------------------------+-------------------------------------------------------+-------------------------------+
+| **3.11, 3.12, 3.13** | .. raw:: html | ... |
+| | | |
+| | Fully Supported | |
+| | | |
++-----------------------------+-------------------------------------------------------+-------------------------------+
+| **3.14** | .. raw:: html | May require custom index |
+| | | |
+| | Check Notes | |
+| | | |
++-----------------------------+-------------------------------------------------------+-------------------------------+
+
+
+Virtual Environments
+====================
+
+TwitchIO recommends the use of Virtual Environments (venvs).
+
+You can read more about virtual environments `here. `_
+Below are some simple commands to help you get started with a **venv** and TwitchIO.
+
+Windows
+-------
+
+.. code:: shell
+
+ # Change into your projects root directory or open a terminal there...
+ cd path/to/project
+
+ # Create the virtual environment...
+ # Replace 3.11 with the Python version you want to use...
+ # You can check what Python versions you have installed with:
+ # py -0
+ py -3.11 -m venv venv
+
+ # Activate your venv...
+ # Everytime you want to use your venv in a new terminal you should run this command...
+ # You will know your venv is activated if you see the (venv) prefix in your terminal...
+ venv/Scripts/Activate
+
+ # Install your packages...
+ pip install -U twitchio --pre
+
+ # You can use your venv python while it's activated simply by running py
+ # E.g. py main.py
+ # E.g. py --version
+ py main.py
+
+ # You can deactivate your venv in this terminal with
+ deactivate
+
+ # REMEMBER!
+ # You have to re-activate your venv whenever it is deactivated to use for it for you project...
+ # You will know your venv is activated by looking for the (venv) prefix in your terminal
+
+
+Linux & MacOS
+-------------
+
+.. code:: shell
+
+ # Change into your projects root directory or open a terminal there...
+ cd path/to/project
+
+ # Create the virtual environment...
+ # Replace 3.11 with the Python version you want to use...
+ python3.11 -m venv venv
+
+ # Activate your venv...
+ # Everytime you want to use your venv in a new terminal you should run this command...
+ # You will know your venv is activated if you see the (venv) prefix in your terminal...
+ source venv/bin/activate
+
+ # Install your packages...
+ pip install -U twitchio --pre
+
+ # You can use your venv python while it's activated simply by running python
+ # E.g. python main.py
+ # E.g. python --version
+ python main.py
+
+ # You can deactivate your venv in this terminal with
+ deactivate
+
+ # REMEMBER!
+ # You have to re-activate your venv whenever it is deactivated to use for it for you project...
+ # You will know your venv is activated by looking for the (venv) prefix in your terminal
+
+
+Extra and Optional Dependencies
+===============================
+
+.. raw:: html
+
+ This version of TwitchIO is a Beta Version!
+
+
+
+To use certain optional features of TwitchIO you will have to install the required packages needed to run them.
+The following commands can be used to install TwitchIO with optional features:
+
+
+**To use the StarletteAdapter**:
+
+.. code:: shell
+
+ pip install -U twitchio[starlette] --pre
+
+
+**For development purposes**:
+
+.. code:: shell
+
+ pip install -U twitchio[dev] --pre
+
+
+**For documentation purposes**:
+
+.. code:: shell
+
+ pip install -U twitchio[docs] --pre
+
+
+Custom Index
+============
+
+Using TwitchIO with ``Python >= 3.14`` may require the use of a custom pip index.
+The index allows pip to fetch pre-built wheels for some dependencies that may require build-tools for C/C++ due to not having released their own wheels for recent versions of Python.
+
+Usually with time, dependencies will eventually release wheels for new Python releases.
+For convenience we provide an index thanks to `Abstract Umbra `_
+
+
+**To install with prebuilt wheels:**
+
+.. code:: shell
+
+ pip install -U twitchio --pre --extra-index-url https://pip.pythonista.gg
+
+
+Installation Issues
+===================
+Make sure you have the latest version of Python installed, or if you prefer, a Python version of 3.11 or greater.
+If you have any other issues feel free to search for duplicates and then create a new issue on `GitHub `_ with as much detail as possible. Including providing the output of pip, your OS details and Python version.
\ No newline at end of file
diff --git a/docs/getting-started/migrating.rst b/docs/getting-started/migrating.rst
new file mode 100644
index 00000000..3669177a
--- /dev/null
+++ b/docs/getting-started/migrating.rst
@@ -0,0 +1,290 @@
+.. _Migrating Guide:
+
+Migrating
+#########
+
+.. warning::
+
+ This document is a work in progress.
+
+
+Version 2 to version 3 has many breaking changes, new systems, new implementations and new designs that will require rewriting
+any existing applications. Some of these changes will feel similar (such as ext.commands), while others have been completely
+removed (such as IRC, see: :ref:`FAQ `), or are new or significantly changed. This document serves to hopefully make
+it easier to move over to version 3.
+
+
+Python Version Changes
+======================
+
+TwitchIO version 3 uses a minimum Python version of ``3.11``. See: :ref:`Installing ` for more information.
+
+
+Token Management and OAuth
+==========================
+
+One of the main focuses of version 3 was to make it easier for developers to manage authentication tokens.
+
+When starting or restarting the :class:`twitchio.Client` a new ``App Token`` is automatically (re)generated. This behaviour can be
+changed by passing an ``App Token`` to :meth:`~twitchio.Client.start`, :meth:`~twitchio.Client.run` or :meth:`~twitchio.Client.login`
+however since there are no ratelimits on this endpoint, it is generally safer and easier to use the deafult.
+
+The following systems have been added to help aid in token management in version 3:
+
+**Web Adapters:**
+
+- :class:`twitchio.web.AiohttpAdapter`
+- :class:`twitchio.web.StarletteAdapter`
+
+**Client:**
+
+- :attr:`twitchio.Client.tokens`
+- :meth:`twitchio.Client.add_token`
+- :meth:`twitchio.Client.remove_token`
+- :meth:`twitchio.Client.load_tokens`
+- :meth:`twitchio.Client.save_tokens`
+
+**Events:**
+
+- :func:`twitchio.event_oauth_authorized`
+
+**Scopes:**
+
+- :class:`twitchio.Scopes`
+
+
+By default a web adapter is started and ran alongside your application when it starts. The web adapters are ready with
+batteries-included to handle OAuth and EventSub via webhooks.
+
+The default redirect URL for OAuth is ``http://localhost:4343/oauth/callback``
+which can be added to your application in the `Twitch Developer Console `_. You can then
+visit ``http://localhost:4343/oauth?scopes=`` with a list of provided scopes to authenticate and add the ``User Token`` to the
+:class:`~twitchio.Client`.
+
+After closing the :class:`~twitchio.Client` gracefully, all tokens currently managed will be
+saved to a file named ``.tio.tokens.json``. This same file is also read and loaded when the :class:`~twitchio.Client` starts.
+
+Consider reading the :ref:`Quickstart Guide ` for an example on this flow, and implementing a SQL Database as
+an alternative for token storage.
+
+Internally version 3 also implements a Managed HTTPClient which handles validating and refreshing loaded tokens automatically.
+
+Another benefit of the Managed HTTPClient is it attempts to find and use the appropriate token for each request, unless explicitly
+overriden, which can be done on most on methods that allow it via the ``token_for`` or ``token`` parameters.
+
+
+Running a Client/Bot
+====================
+
+Running a :class:`~twitchio.Client` or :class:`~twitchio.ext.commands.Bot` hasn't changed much since version 2, however both
+have now implemented ``__aenter__`` and ``__aexit__`` which allows them to be used in a Async Context Manager for easier
+management of close down and cleanup. These changes along with some async internals have also been reflected in :meth:`~twitchio.Client.run`.
+
+You can also :meth:`~twitchio.Client.login` the :class:`~twitchio.Client` without running a continuous asyncio event loop, E.g.
+for making HTTP Requests only or for using the :class:`~twitchio.Client` in an already running event loop.
+
+However we recommend following the below as a simple and modern way of starting your Client/Bot:
+
+.. code:: python3
+
+ import asyncio
+
+ ...
+
+
+ if __name__ == "__main__":
+
+ async def main() -> None:
+ twitchio.utils.setup_logging()
+
+ async with Bot() as bot:
+ await bot.start()
+
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ ...
+
+
+**Added:**
+
+- :meth:`twitchio.Client.login`
+
+**Changed:**
+
+- :meth:`twitchio.Client.start`
+- :meth:`twitchio.Client.run`
+
+
+Logging
+=======
+
+Version 3 adds a logging helper which allows for a simple and easier way to setup logging formatting for your application.
+
+As version 3 uses logging heavily and encourages developers to use logging in place of ``print`` statements where appropriate
+we would encourage you to call this function. Usually you would call this helper *before* starting the client for each logger.
+
+If you are calling this on the ``root`` logger (default), you should only need to call this function once.
+
+**Added:**
+
+- :func:`twitchio.utils.setup_logging()`
+
+
+Assets and Colours
+==================
+
+In version 2, all images, colour/hex codes and other assets were usually just strings of the hex or a URL pointing to the
+asset.
+
+In version 3 all assets are now a special class :class:`twitchio.Asset` which can be used to download, save and manage
+the various assets available from Twitch such as :attr:`twitchio.Game.box_art`.
+
+Any colour that Twitch returns as a valid HEX or RGB code is also a special class :class:`twitchio.Colour`. This class
+implements various dunders such as ``__format__`` which will help in using the :class:`~twitchio.Colour` in strings,
+other helpers to convert the colour data to different formats, and classmethod helpers to retrieve default colours.
+
+**Added:**
+
+- :class:`twitchio.Asset`
+- :class:`twitchio.Colour`
+- :class:`twitchio.Color` (An alias to :class:`twitchio.Colour`)
+
+
+HTTP Async Iterator
+===================
+
+In previous versions all requests made to Twitch were made in a single call and did not have an option to paginate.
+
+With version 3 you will notice paginated endpoints now return a :class:`twitchio.HTTPAsyncIterator`. This class is a async
+iterator which allows the following semantics:
+
+``await method(...)``
+
+**or**
+
+``async for item in method(...)``
+
+This allows fetching a flattened list of the first page of results only (``await``) or making paginated requests as an iterator
+(``async for``).
+
+You can flatten a paginated request by using a list comprehension.
+
+.. code-block:: python3
+
+ # Flatten and return first page (20 results)
+ streams = await bot.fetch_streams()
+
+ # Flatten and return up to 1000 results (max 100 per page) which equates to 10 requests...
+ streams = [stream async for stream in bot.fetch_streams(first=100, max_results=1000)]
+
+ # Loop over results until we manually stop...
+ async for item in bot.fetch_streams(first=100, max_results=1000):
+ # Some logic...
+ ...
+ break
+
+Twitch endpoints only allow a max of ``100`` results per page, with a default of ``20``.
+
+You can identify endpoints which support the :class:`twitchio.HTTPAsyncIterator` by looking for the following on top of the
+function in the docs:
+
+.. raw:: html
+
+
+
+
+ await
+ .endpoint(...)
+ ->
+ list[T]
+ async for item in .endpoint(...):
+
+
+
+
+
+
+**Added:**
+
+- :class:`twitchio.HTTPAsyncIterator`
+
+Events
+======
+
+Events in version 3 have changed internally, however user facing should be fairly similar. One main difference to note
+is that all events accept exactly one argument, a payload containing relevant event data, with the exception of
+:func:`twitchio.event_ready` which accepts exactly ``0`` arguments, and some command events which accept
+:class:`twitchio.ext.commands.Context` only.
+
+For a list of events and their relevant payloads see the :ref:`Event Reference `.
+
+**Changed:**
+
+- :ref:`Events ` now accept a single argument, ``payload`` or :class:`~twitchio.ext.commands.Context`, with one exception (:func:`twitchio.event_ready`).
+
+
+Wait For
+========
+
+:meth:`twitchio.Client.wait_for` has changed internally however should act similiary to previous versions with some notes:
+
+- ``predicate`` and ``timeout`` are now both keyword-only arguments.
+- ``predicate`` is now async.
+
+:meth:`twitchio.Client.wait_for` returns the payload of the waiting event.
+
+To wait until the bot is ready, consider using :meth:`twitchio.Client.wait_until_ready`.
+
+**Changed:**
+
+- :meth:`twitchio.Client.wait_for`
+ - ``predicate`` and ``timeout`` are now both keyword-only arguments.
+ - ``predicate`` is now async.
+- ``Client.wait_for_ready`` is now :meth:`twitchio.Client.wait_until_ready`
+
+
+
+Changelog
+=========
+
+Added
+~~~~~
+
+- :class:`twitchio.web.AiohttpAdapter`
+- :class:`twitchio.web.StarletteAdapter`
+
+Client:
+
+- :attr:`twitchio.Client.tokens`
+- :meth:`twitchio.Client.add_token`
+- :meth:`twitchio.Client.remove_token`
+- :meth:`twitchio.Client.load_tokens`
+- :meth:`twitchio.Client.save_tokens`
+- :meth:`twitchio.Client.login`
+
+Utils/Helpers:
+
+- :class:`twitchio.Asset`
+- :class:`twitchio.Colour`
+- :class:`twitchio.Color` (An alias to :class:`twitchio.Colour`)
+- :func:`twitchio.utils.setup_logging()`
+- :class:`twitchio.Scopes`
+- :class:`twitchio.HTTPAsyncIterator`
+
+Events:
+
+- :func:`twitchio.event_oauth_authorized`
+
+Changed
+~~~~~~~
+
+Client:
+
+- :meth:`twitchio.Client.start`
+- :meth:`twitchio.Client.run`
+- :meth:`twitchio.Client.wait_for`
+ - ``predicate`` and ``timeout`` are now both keyword-only arguments.
+ - ``predicate`` is now async.
+- ``Client.wait_for_ready`` is now :meth:`twitchio.Client.wait_until_ready`
+- ``Client.create_user`` is now :meth:`twitchio.Client.create_partialuser`
diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst
new file mode 100644
index 00000000..dbc91081
--- /dev/null
+++ b/docs/getting-started/quickstart.rst
@@ -0,0 +1,197 @@
+.. _quickstart:
+
+
+Quickstart
+###########
+
+This mini tutorial will serve as an entry point into TwitchIO 3. After it you should have a small working bot and a basic understanding of TwitchIO.
+
+If you haven't already installed TwitchIO 3, please check :doc:`installing`.
+
+
+Creating a Twitch Application
+==============================
+
+#. Browse to `Twitch Developer Console `_ and Create an Application
+#. Add: http://localhost:4343/oauth/callback as the callback URL
+#. Make a note of your CLIENT_ID and CLIENT_SECRET.
+
+A Minimal bot
+==============
+
+For this example we will be using sqlite3 as our token database.
+Since TwitchIO 3 is fully asynchronous we will be using `asqlite` as our library of choice.
+
+.. code:: shell
+
+ pip install -U git+https://github.com/Rapptz/asqlite.git
+
+Before running the code below, there just a couple more steps we need to take.
+
+#. Create a new Twitch account. This will be the dedicated bot account.
+#. Enter your CLIENT_ID, CLIENT_SECRET, BOT_ID and OWNER_ID into the placeholders in the below example.
+#. Comment out everything in ``setup_hook``.
+#. Run the bot.
+#. Open a new browser / incognito mode, log in as the bot account and visit http://localhost:4343/oauth?scopes=user:read:chat%20user:write:chat%20user:bot
+#. In your main browser whilst logged in as your account, visit http://localhost:4343/oauth?scopes=channel:bot
+#. Stop the bot and uncomment everything in ``setup_hook``.
+#. Start the bot.
+
+**You only have to do this sequence of steps once. Or if the scopes need to change.**
+
+.. code:: python3
+
+ import asyncio
+ import logging
+ import sqlite3
+
+ import asqlite
+ import twitchio
+ from twitchio.ext import commands
+ from twitchio import eventsub
+
+
+ LOGGER: logging.Logger = logging.getLogger("Bot")
+
+ CLIENT_ID: str = "..." # The CLIENT ID from the Twitch Dev Console
+ CLIENT_SECRET: str = "..." # The CLIENT SECRET from the Twitch Dev Console
+ BOT_ID = "..." # The Account ID of the bot user...
+ OWNER_ID = "..." # Your personal User ID..
+
+
+ class Bot(commands.Bot):
+ def __init__(self, *, token_database: asqlite.Pool) -> None:
+ self.token_database = token_database
+ super().__init__(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ bot_id=BOT_ID,
+ owner_id=OWNER_ID,
+ prefix="!",
+ )
+
+ async def setup_hook(self) -> None:
+ # Add our component which contains our commands...
+ await self.add_component(MyComponent(self))
+
+ # Subscribe to read chat (event_message) from our channel as the bot...
+ # This creates and opens a websocket to Twitch EventSub...
+ subscription = eventsub.ChatMessageSubscription(broadcaster_user_id=OWNER_ID, user_id=BOT_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ # Subscribe and listen to when a stream goes live..
+ # For this example listen to our own stream...
+ subscription = eventsub.StreamOnlineSubscription(broadcaster_user_id=OWNER_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ async def add_token(self, token: str, refresh: str) -> twitchio.authentication.ValidateTokenPayload:
+ # Make sure to call super() as it will add the tokens interally and return us some data...
+ resp: twitchio.authentication.ValidateTokenPayload = await super().add_token(token, refresh)
+
+ # Store our tokens in a simple SQLite Database when they are authorized...
+ query = """
+ INSERT INTO tokens (user_id, token, refresh)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id)
+ DO UPDATE SET
+ token = excluded.token,
+ refresh = excluded.refresh;
+ """
+
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query, (resp.user_id, token, refresh))
+
+ LOGGER.info("Added token to the database for user: %s", resp.user_id)
+ return resp
+
+ async def load_tokens(self, path: str | None = None) -> None:
+ # We don't need to call this manually, it is called in .login() from .start() internally...
+
+ async with self.token_database.acquire() as connection:
+ rows: list[sqlite3.Row] = await connection.fetchall("""SELECT * from tokens""")
+
+ for row in rows:
+ await self.add_token(row["token"], row["refresh"])
+
+ async def setup_database(self) -> None:
+ # Create our token table, if it doesn't exist..
+ query = """CREATE TABLE IF NOT EXISTS tokens(user_id TEXT PRIMARY KEY, token TEXT NOT NULL, refresh TEXT NOT NULL)"""
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query)
+
+ async def event_ready(self) -> None:
+ LOGGER.info("Successfully logged in as: %s", self.bot_id)
+
+
+ class MyComponent(commands.Component):
+ def __init__(self, bot: Bot):
+ # Passing args is not required...
+ # We pass bot here as an example...
+ self.bot = bot
+
+ # We use a listener in our Component to display the messages received.
+ @commands.Component.listener()
+ async def event_message(self, payload: twitchio.ChatMessage) -> None:
+ print(f"[{payload.broadcaster.name}] - {payload.chatter.name}: {payload.text}")
+
+ @commands.command(aliases=["hello", "howdy", "hey"])
+ async def hi(self, ctx: commands.Context) -> None:
+ """Simple command that says hello!
+
+ !hi, !hello, !howdy, !hey
+ """
+ await ctx.reply(f"Hello {ctx.chatter.mention}!")
+
+ @commands.group(invoke_fallback=True)
+ async def socials(self, ctx: commands.Context) -> None:
+ """Group command for our social links.
+
+ !socials
+ """
+ await ctx.send("discord.gg/..., youtube.com/..., twitch.tv/...")
+
+ @socials.command(name="discord")
+ async def socials_discord(self, ctx: commands.Context) -> None:
+ """Sub command of socials that sends only our discord invite.
+
+ !socials discord
+ """
+ await ctx.send("discord.gg/...")
+
+ @commands.command(aliases=["repeat"])
+ @commands.is_moderator()
+ async def say(self, ctx: commands.Context, *, content: str) -> None:
+ """Moderator only command which repeats back what you say.
+
+ !say hello world, !repeat I am cool LUL
+ """
+ await ctx.send(content)
+
+ @commands.Component.listener()
+ async def event_stream_online(self, payload: twitchio.StreamOnline) -> None:
+ # Event dispatched when a user goes live from the subscription we made above...
+
+ # Keep in mind we are assuming this is for ourselves
+ # others may not want your bot randomly sending messages...
+ await payload.broadcaster.send_message(
+ sender=self.bot.bot_id,
+ message=f"Hi... {payload.broadcaster}! You are live!",
+ )
+
+
+ def main() -> None:
+ twitchio.utils.setup_logging(level=logging.INFO)
+
+ async def runner() -> None:
+ async with asqlite.create_pool("tokens.db") as tdb, Bot(token_database=tdb) as bot:
+ await bot.setup_database()
+ await bot.start()
+
+ try:
+ asyncio.run(runner())
+ except KeyboardInterrupt:
+ LOGGER.warning("Shutting down due to KeyboardInterrupt...")
+
+
+ if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/docs/images/commands_arguments_1.png b/docs/images/commands_arguments_1.png
deleted file mode 100644
index 6039a57f..00000000
Binary files a/docs/images/commands_arguments_1.png and /dev/null differ
diff --git a/docs/images/commands_arguments_2.png b/docs/images/commands_arguments_2.png
deleted file mode 100644
index 18ccf9d0..00000000
Binary files a/docs/images/commands_arguments_2.png and /dev/null differ
diff --git a/docs/images/commands_arguments_3.png b/docs/images/commands_arguments_3.png
deleted file mode 100644
index 2353167e..00000000
Binary files a/docs/images/commands_arguments_3.png and /dev/null differ
diff --git a/docs/images/commands_arguments_4.png b/docs/images/commands_arguments_4.png
deleted file mode 100644
index 555cde9f..00000000
Binary files a/docs/images/commands_arguments_4.png and /dev/null differ
diff --git a/docs/images/commands_arguments_5.png b/docs/images/commands_arguments_5.png
deleted file mode 100644
index ab0c18da..00000000
Binary files a/docs/images/commands_arguments_5.png and /dev/null differ
diff --git a/docs/images/commands_basic_1.png b/docs/images/commands_basic_1.png
deleted file mode 100644
index 6be7cfad..00000000
Binary files a/docs/images/commands_basic_1.png and /dev/null differ
diff --git a/docs/images/commands_basic_2.png b/docs/images/commands_basic_2.png
deleted file mode 100644
index 1fda7024..00000000
Binary files a/docs/images/commands_basic_2.png and /dev/null differ
diff --git a/docs/images/commands_errors_1.png b/docs/images/commands_errors_1.png
deleted file mode 100644
index 8fa9f9b1..00000000
Binary files a/docs/images/commands_errors_1.png and /dev/null differ
diff --git a/docs/images/commands_errors_2.png b/docs/images/commands_errors_2.png
deleted file mode 100644
index d6904f26..00000000
Binary files a/docs/images/commands_errors_2.png and /dev/null differ
diff --git a/docs/images/commands_errors_3.png b/docs/images/commands_errors_3.png
deleted file mode 100644
index 1fc39fba..00000000
Binary files a/docs/images/commands_errors_3.png and /dev/null differ
diff --git a/docs/images/commands_parsing_1.png b/docs/images/commands_parsing_1.png
deleted file mode 100644
index eb57e8b5..00000000
Binary files a/docs/images/commands_parsing_1.png and /dev/null differ
diff --git a/docs/images/commands_parsing_2.png b/docs/images/commands_parsing_2.png
deleted file mode 100644
index 9b683f71..00000000
Binary files a/docs/images/commands_parsing_2.png and /dev/null differ
diff --git a/docs/images/commands_parsing_3.png b/docs/images/commands_parsing_3.png
deleted file mode 100644
index 0c3fe1a8..00000000
Binary files a/docs/images/commands_parsing_3.png and /dev/null differ
diff --git a/docs/images/commands_parsing_4.png b/docs/images/commands_parsing_4.png
deleted file mode 100644
index e9fea863..00000000
Binary files a/docs/images/commands_parsing_4.png and /dev/null differ
diff --git a/docs/index.rst b/docs/index.rst
index a43dae78..71c1d215 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,122 +1,88 @@
-.. meta::
- :title: TwitchIO Documentation
- :description: Documentation for TwitchIO, the asynchronous Python wrapper for Twitch.tv.
- :language: en-US
- :keywords: twitchio, twitch, python api
- :copyright: TwitchIO. 2017 - Present
-
-
-.. raw:: html
-
-
-
-
-
-
-
- A fully asynchronous Python IRC, API, EventSub and PubSub library for Twitch.
-
-
Featuring:
-
-
Full asynchronous design.
-
Covers 100% of the Twitch API.
-
IRC Commands extension for creating powerful chat bots.
-
EventSub and PubSub support.
-
Helper extensions for Music/Sounds and Background Tasks.
-
Object orientated design with stateful objects.
-
-
-
-
Getting Started
-
- For help with getting started with the library for the first time.
-
-
-
- Consider joining the Official Discord server for a fast response to help.
-
-
- For issues and contributing with the library, visit:GitHub
-
-
-.. rst-class:: index-display-none
-.. toctree::
- :maxdepth: 1
- :caption: Getting Started
+TwitchIO
+#########
- installing
- quickstart
- faq
+.. warning::
-.. rst-class:: index-display-none
-.. toctree::
- :maxdepth: 1
- :caption: API Reference
+ This is a beta release. Please take care using this release in production ready applications.
+
+
+TwitchIO is a powerful, asynchronous Python library for `twitch.tv `_.
+
+TwitchIO aims to be intuitive and easy to use, using modern async Python and following strict typing with stateful objects
+and plug-and-play extensions.
+
+TwitchIO is more than a simple wrapper, providing ease of use when accessing the Twitch API with powerful extensions
+to help create and manage applications and Twitch Chat Bots. TwitchIO is inspired by `discord.py `_.
+
+
+**Features:**
+
+- Modern ``async`` Python using ``asyncio``
+- Fully annotated and complies with the ``pyright`` strict type-checker
+- Intuitive with ease of use, using modern object orientated design
+- Feature full including extensions for ``chat bots``, running ``routine tasks`` and ``playing sounds`` on stream (Conduits support soon...)
+- Easily manage ``OAuth Tokens`` and data
+- Built-in ``EventSub`` support via both ``Webhook`` and ``Websockets``
+
+
+Help and support
+----------------
- twitchio
- reference
+- For issues or bugs please visit: `GitHub `_
+- See our :ref:`faqs`
+- Visit our `Discord `_ for help using TwitchIO
+
+
+.. warning::
+
+ This document is a work in progress.
+
+
+Getting Started
+---------------
-.. rst-class:: index-display-none
.. toctree::
:maxdepth: 1
- :caption: Extensions API's:
+ :caption: Getting Started
+
+ getting-started/installing
+ getting-started/migrating
+ getting-started/quickstart
+ getting-started/changelog
+ getting-started/faq
- exts/commands
- exts/pubsub
- exts/eventsub
- exts/routines
- exts/sounds
+References
+----------
-.. raw:: html
+.. toctree::
+ :maxdepth: 1
+ :caption: API References
-
-
+.. toctree::
+ :maxdepth: 1
+ :caption: Utils
-* :ref:`genindex`
-* :ref:`search`
+ references/utils
diff --git a/docs/installing.rst b/docs/installing.rst
deleted file mode 100644
index a307ab96..00000000
--- a/docs/installing.rst
+++ /dev/null
@@ -1,79 +0,0 @@
-:orphan:
-
-Installing
-============
-TwitchIO 2 requires Python 3.7+.
-You can download the latest version of Python `here `_.
-
-
-**Windows:**
-
-.. code:: sh
-
- py -3.9 -m pip install -U twitchio
-
-**Linux:**
-
-.. code:: sh
-
- python3.9 -m pip install -U twitchio
-
-
-Debugging
-----------
-Make sure you have the latest version of Python installed, or if you prefer, a Python version of 3.7 or greater.
-
-If you have have any other issues feel free to search for duplicates and then create a new issue on GitHub with as much detail as
-possible. Including providing the output of pip, your OS details and Python version.
-
-
-Extras
--------
-Twitchio has some extra downloaders available to modify the library.
-Due to some outdated binaries on the pypi package index, when using Python 3.11+, you'll want to make use of our custom pypi
-index for these extras. You can access this index by doing the following (replace your-extra with the extra you want to use):
-
-.. code:: sh
-
- python3 -m pip install -U twitchio[your-extra] --extra-index-url https://pip.twitchio.dev/
-
-Or, on windows:
-
-.. code:: sh
-
- py -3.11 -m pip install -U twitchio[your-extra] --extra-index-url https://pip.twitchio.dev/
-
-
-If you do not wish to use our custom index, you can build the wheels yourself by installing cython through pip prior to installing the extra.
-Note that you will need C build tools installed to be able to do this.
-
-Extra: speed
-++++++++++++++
-The speed extra will install dependancies built in C that are considerably faster than their pure-python equivalents.
-You can install the speed extra by doing:
-
-.. code:: sh
-
- python3 -m pip install -U twitchio[speed] --extra-index-url https://pip.twitchio.dev/
-
-Or, on windows:
-
-.. code:: sh
-
- py -3.11 -m pip install -U twitchio[speed] --extra-index-url https://pip.twitchio.dev/
-
-Extra: sounds
-+++++++++++++++
-The sounds extra installs extra dependancies for using the sounds ext.
-If you wish to use the sounds ext, you will need to install this extra, which you can do by doing the following:
-
-
-.. code:: sh
-
- python3 -m pip install -U twitchio[sounds] --extra-index-url https://pip.twitchio.dev/
-
-Or, on windows:
-
-.. code:: sh
-
- py -3.11 -m pip install -U twitchio[sounds] --extra-index-url https://pip.twitchio.dev/
\ No newline at end of file
diff --git a/docs/make.bat b/docs/make.bat
deleted file mode 100644
index 922152e9..00000000
--- a/docs/make.bat
+++ /dev/null
@@ -1,35 +0,0 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
- set SPHINXBUILD=sphinx-build
-)
-set SOURCEDIR=.
-set BUILDDIR=_build
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
- echo.
- echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
- echo.installed, then set the SPHINXBUILD environment variable to point
- echo.to the full path of the 'sphinx-build' executable. Alternatively you
- echo.may add the Sphinx directory to PATH.
- echo.
- echo.If you don't have Sphinx installed, grab it from
- echo.http://sphinx-doc.org/
- exit /b 1
-)
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-
-:end
-popd
diff --git a/docs/pa.txt b/docs/pa.txt
deleted file mode 100644
index 667bee49..00000000
--- a/docs/pa.txt
+++ /dev/null
@@ -1 +0,0 @@
-pyaudio==0.2.11
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
deleted file mode 100644
index b19cb9ab..00000000
--- a/docs/quickstart.rst
+++ /dev/null
@@ -1,126 +0,0 @@
-:orphan:
-
-Quickstart
-=============
-This mini tutorial will serve as an entry point into TwitchIO 2.
-After it you should have a small working bot and a basic understanding of TwitchIO.
-
-If you haven't already installed TwitchIO 2, check out :doc:`installing`.
-
-
-Tokens and Scopes
--------------------
-For the purpose of this tutorial we will only be using an OAuth Token with permissions to read and write to chat.
-If you require custom scopes, please make sure you select them.
-
-Visit `Token Generator `_ and select the Bot Chat Token.
-After selecting this you can copy your Access Token somewhere safe.
-
-
-Basic Bot
------------
-TwitchIO 2 can be run in multiple different ways, (as a client only, as a bot using extensions, with HTTP requests etc).
-Here we will be using the commands extension of TwitchIO to create a Chat Bot.
-
-.. note::
-
- The TwitchIO commands extension has all the functionality of the Client and HTTPClient, plus more.
-
-.. code:: python
-
- from twitchio.ext import commands
-
-
- class Bot(commands.Bot):
-
- def __init__(self):
- # Initialise our Bot with our access token, prefix and a list of channels to join on boot...
- # prefix can be a callable, which returns a list of strings or a string...
- # initial_channels can also be a callable which returns a list of strings...
- super().__init__(token='ACCESS_TOKEN', prefix='?', initial_channels=['...'])
-
- async def event_ready(self):
- # Notify us when everything is ready!
- # We are logged in and ready to chat and use commands...
- print(f'Logged in as | {self.nick}')
- print(f'User id is | {self.user_id}')
-
- @commands.command()
- async def hello(self, ctx: commands.Context):
- # Here we have a command hello, we can invoke our command with our prefix and command name
- # e.g ?hello
- # We can also give our commands aliases (different names) to invoke with.
-
- # Send a hello back!
- # Sending a reply back to the channel is easy... Below is an example.
- await ctx.send(f'Hello {ctx.author.name}!')
-
-
- bot = Bot()
- bot.run()
- # bot.run() is blocking and will stop execution of any below code here until stopped or closed.
-
-
-The above example listens to one event, `event_ready`. If we want to listen to other events,
-we can simply add them to our Bot class.
-
-For an exhaustive list of events, visit: `Event Reference `_
-
-**Let's add an** `event_message` **which will listen for all messages the bot can see:**
-
-.. code:: py
-
- from twitchio.ext import commands
-
-
- class Bot(commands.Bot):
-
- def __init__(self):
- # Initialise our Bot with our access token, prefix and a list of channels to join on boot...
- # prefix can be a callable, which returns a list of strings or a string...
- # initial_channels can also be a callable which returns a list of strings...
- super().__init__(token='ACCESS_TOKEN', prefix='?', initial_channels=['...'])
-
- async def event_ready(self):
- # Notify us when everything is ready!
- # We are logged in and ready to chat and use commands...
- print(f'Logged in as | {self.nick}')
- print(f'User id is | {self.user_id}')
-
- async def event_message(self, message):
- # Messages with echo set to True are messages sent by the bot...
- # For now we just want to ignore them...
- if message.echo:
- return
-
- # Print the contents of our message to console...
- print(message.content)
-
- # Since we have commands and are overriding the default `event_message`
- # We must let the bot know we want to handle and invoke our commands...
- await self.handle_commands(message)
-
- @commands.command()
- async def hello(self, ctx: commands.Context):
- # Here we have a command hello, we can invoke our command with our prefix and command name
- # e.g ?hello
- # We can also give our commands aliases (different names) to invoke with.
-
- # Send a hello back!
- # Sending a reply back to the channel is easy... Below is an example.
- await ctx.send(f'Hello {ctx.author.name}!')
-
-
- bot = Bot()
- bot.run()
- # bot.run() is blocking and will stop execution of any below code here until stopped or closed.
-
-
-The above example is similar to our original code, though this time we have added in a common event, `event_message`.
-When using `event_message`, as shown above, some things need to be taken into consideration.
-
-Mainly echo messages and the handling of commands. If you do not handle these appropriately you may have undesired
-effects on your bot.
-
-You should now have a working Twitch Chat Bot that prints messages to console, and responds to the command `?hello`.
-If you are stuck, please visit the :doc:`faq` page or `Join our Discord `_.
\ No newline at end of file
diff --git a/docs/reference.rst b/docs/reference.rst
deleted file mode 100644
index c4d7b28a..00000000
--- a/docs/reference.rst
+++ /dev/null
@@ -1,491 +0,0 @@
-.. currentmodule:: twitchio
-
-Dataclass Reference
-=====================
-
-ActiveExtension
-------------------
-.. attributetable:: ActiveExtension
-
-.. autoclass:: ActiveExtension
- :members:
- :inherited-members:
-
-AdSchedule
-------------
-.. attributetable:: AdSchedule
-
-.. autoclass:: AdSchedule
- :members:
-
-AutomodCheckMessage
----------------------
-.. attributetable:: AutomodCheckMessage
-
-.. autoclass:: AutomodCheckMessage
- :members:
- :inherited-members:
-
-AutomodCheckResponse
-----------------------
-.. attributetable:: AutomodCheckResponse
-
-.. autoclass:: AutomodCheckResponse
- :members:
- :inherited-members:
-
-Ban
-----------
-.. attributetable:: Ban
-
-.. autoclass:: Ban
- :members:
- :inherited-members:
-
-BanEvent
-----------
-.. attributetable:: BanEvent
-
-.. autoclass:: BanEvent
- :members:
- :inherited-members:
-
-BitLeaderboardUser
---------------------
-.. attributetable:: BitLeaderboardUser
-
-.. autoclass:: BitLeaderboardUser
- :members:
-
-BitsLeaderboard
-------------------
-.. attributetable:: BitsLeaderboard
-
-.. autoclass:: BitsLeaderboard
- :members:
- :inherited-members:
-
-Channel
----------
-.. attributetable:: Channel
-
-.. autoclass:: Channel
- :members:
- :inherited-members:
-
-ChannelEmote
-------------
-.. attributetable:: ChannelEmote
-
-.. autoclass:: ChannelEmote
- :members:
- :inherited-members:
-
-ChannelFollowerEvent
----------------------
-.. attributetable:: ChannelFollowerEvent
-
-.. autoclass:: ChannelFollowerEvent
- :members:
- :inherited-members:
-
-ChannelFollowingEvent
----------------------
-.. attributetable:: ChannelFollowingEvent
-
-.. autoclass:: ChannelFollowingEvent
- :members:
- :inherited-members:
-
-ChannelInfo
-------------
-.. attributetable:: ChannelInfo
-
-.. autoclass:: ChannelInfo
- :members:
- :inherited-members:
-
-ChannelTeams
--------------
-.. attributetable:: ChannelTeams
-
-.. autoclass:: ChannelTeams
- :members:
- :inherited-members:
-
-ChatBadge
-----------
-.. attributetable:: ChatBadge
-
-.. autoclass:: ChatBadge
- :members:
- :inherited-members:
-
-ChatBadgeVersions
-------------------
-.. attributetable:: ChatBadgeVersions
-
-.. autoclass:: ChatBadgeVersions
- :members:
- :inherited-members:
-
-ChatSettings
--------------
-.. attributetable:: ChatSettings
-
-.. autoclass:: ChatSettings
- :members:
- :inherited-members:
-
-Chatter
---------
-.. attributetable:: PartialChatter
-
-.. autoclass:: PartialChatter
- :members:
- :inherited-members:
-
-.. attributetable:: Chatter
-
-.. autoclass:: Chatter
- :members:
-
-ChatterColor
--------------
-.. attributetable:: ChatterColor
-
-.. autoclass:: ChatterColor
- :members:
- :inherited-members:
-
-CharityCampaign
-----------------
-.. attributetable:: CharityCampaign
-
-.. autoclass:: CharityCampaign
- :members:
- :inherited-members:
-
-.. attributetable:: CharityValues
-
-.. autoclass:: CharityValues
- :members:
- :inherited-members:
-
-CheerEmote
-------------
-.. attributetable:: CheerEmote
-
-.. autoclass:: CheerEmote
- :members:
- :inherited-members:
-
-CheerEmoteTier
-----------------
-.. attributetable:: CheerEmoteTier
-
-.. autoclass:: CheerEmoteTier
- :members:
- :inherited-members:
-
-Clip
-------
-.. attributetable:: Clip
-
-.. autoclass:: Clip
- :members:
- :inherited-members:
-
-ContentClassificationLabel
----------------------------
-.. attributetable:: ContentClassificationLabel
-
-.. autoclass:: ContentClassificationLabel
- :members:
- :inherited-members:
-
-CustomReward
---------------
-.. attributetable:: CustomReward
-
-.. autoclass:: CustomReward
- :members:
- :inherited-members:
-
-CustomRewardRedemption
-------------------------
-.. attributetable:: CustomRewardRedemption
-
-.. autoclass:: CustomRewardRedemption
- :members:
- :inherited-members:
-
-Emote
-------
-.. attributetable:: Emote
-
-.. autoclass:: Emote
- :members:
-
-Extension
------------
-.. attributetable:: Extension
-
-.. autoclass:: Extension
- :members:
- :inherited-members:
-
-ExtensionBuilder
-------------------
-.. attributetable:: ExtensionBuilder
-
-.. autoclass:: ExtensionBuilder
- :members:
- :inherited-members:
-
-FollowEvent
--------------
-.. attributetable:: FollowEvent
-
-.. autoclass:: FollowEvent
- :members:
- :inherited-members:
-
-Game
-------
-.. attributetable:: Game
-
-.. autoclass:: Game
- :members:
- :inherited-members:
-
-GlobalEmote
-------------
-.. attributetable:: GlobalEmote
-
-.. autoclass:: GlobalEmote
- :members:
- :inherited-members:
-
-Goal
-------
-.. attributetable:: Goal
-
-.. autoclass:: Goal
- :members:
- :inherited-members:
-
-HypeChatData
-
-.. attributetable:: HypeChatData
-
-.. autoclass:: HypeChatData
- :members:
-
-HypeTrainContribution
------------------------
-.. attributetable:: HypeTrainContribution
-
-.. autoclass:: HypeTrainContribution
- :members:
- :inherited-members:
-
-HypeTrainEvent
-----------------
-.. attributetable:: HypeTrainEvent
-
-.. autoclass:: HypeTrainEvent
- :members:
- :inherited-members:
-
-Marker
---------
-.. attributetable:: Marker
-
-.. autoclass:: Marker
- :members:
- :inherited-members:
-
-MaybeActiveExtension
-----------------------
-.. attributetable:: MaybeActiveExtension
-
-.. autoclass:: MaybeActiveExtension
- :members:
- :inherited-members:
-
-Message
----------
-.. attributetable:: Message
-
-.. autoclass:: Message
- :members:
- :inherited-members:
-
-ModEvent
-----------
-.. attributetable:: ModEvent
-
-.. autoclass:: ModEvent
- :members:
- :inherited-members:
-
-Poll
--------------
-.. attributetable:: Poll
-
-.. autoclass:: Poll
- :members:
- :inherited-members:
-
-.. attributetable:: PollChoice
-
-.. autoclass:: PollChoice
- :members:
- :inherited-members:
-
-Predictions
--------------
-.. attributetable:: Prediction
-
-.. autoclass:: Prediction
- :members:
- :inherited-members:
-
-.. attributetable:: Predictor
-
-.. autoclass:: Predictor
- :members:
- :inherited-members:
-
-Raid
------
-.. attributetable:: Raid
-
-.. autoclass:: Raid
- :members:
- :inherited-members:
-
-Schedules
-----------
-.. attributetable:: Schedule
-
-.. autoclass:: Schedule
- :members:
- :inherited-members:
-
-.. attributetable:: ScheduleSegment
-
-.. autoclass:: ScheduleSegment
- :members:
- :inherited-members:
-
-.. attributetable:: ScheduleCategory
-
-.. autoclass:: ScheduleCategory
- :members:
- :inherited-members:
-
-.. attributetable:: ScheduleVacation
-
-.. autoclass:: ScheduleVacation
- :members:
- :inherited-members:
-
-SearchUser
-------------
-.. attributetable:: SearchUser
-
-.. autoclass:: SearchUser
- :members:
-
-ShieldStatus
-------------
-.. attributetable:: ShieldStatus
-
-.. autoclass:: ShieldStatus
- :members:
-
-Stream
-----------
-.. attributetable:: Stream
-
-.. autoclass:: Stream
- :members:
- :inherited-members:
-
-SubscriptionEvent
--------------------
-.. attributetable:: SubscriptionEvent
-
-.. autoclass:: SubscriptionEvent
- :members:
- :inherited-members:
-
-Tag
------
-.. attributetable:: Tag
-
-.. autoclass:: Tag
- :members:
- :inherited-members:
-
-Team
-----------
-.. attributetable:: Team
-
-.. autoclass:: Team
- :members:
- :inherited-members:
-
-Timeout
-----------
-.. attributetable:: Timeout
-
-.. autoclass:: Timeout
- :members:
- :inherited-members:
-
-User
-------
-.. attributetable:: PartialUser
-
-.. autoclass:: PartialUser
- :members:
- :inherited-members:
-
-.. attributetable:: User
-
-.. autoclass:: User
- :members:
-
-UserBan
----------
-.. attributetable:: UserBan
-
-.. autoclass:: UserBan
- :members:
-
-Video
--------
-.. attributetable:: Video
-
-.. autoclass:: Video
- :members:
- :inherited-members:
-
-VideoMarkers
---------------
-.. attributetable:: VideoMarkers
-
-.. autoclass:: VideoMarkers
- :members:
- :inherited-members:
-
-WebhookSubscription
----------------------
-.. attributetable:: WebhookSubscription
-
-.. autoclass:: WebhookSubscription
- :members:
- :inherited-members:
diff --git a/docs/references/client.rst b/docs/references/client.rst
new file mode 100644
index 00000000..c0830a5f
--- /dev/null
+++ b/docs/references/client.rst
@@ -0,0 +1,10 @@
+.. currentmodule:: twitchio
+
+
+Client Reference
+#################
+
+.. attributetable:: twitchio.Client
+
+.. autoclass:: twitchio.Client
+ :members:
\ No newline at end of file
diff --git a/docs/references/events.rst b/docs/references/events.rst
new file mode 100644
index 00000000..333d53b4
--- /dev/null
+++ b/docs/references/events.rst
@@ -0,0 +1,424 @@
+.. currentmodule:: twitchio
+
+.. _Event Ref:
+
+Events Reference
+################
+
+.. warning::
+
+ This document is a work in progress.
+
+
+All events are prefixed with **event_**
+
+.. list-table::
+ :header-rows: 1
+
+ * - Type
+ - Subscription
+ - Event
+ - Payload
+ * - Automod Message Hold
+ - :meth:`~eventsub.AutomodMessageHoldSubscription`
+ - :func:`~twitchio.event_automod_message_hold()`
+ - :class:`~models.eventsub_.AutomodMessageHold`
+ * - Automod Message Update
+ - :meth:`~eventsub.AutomodMessageUpdateSubscription`
+ - :func:`~twitchio.event_automod_message_update()`
+ - :class:`~models.eventsub_.AutomodMessageUpdate`
+ * - Automod Settings Update
+ - :meth:`~eventsub.AutomodSettingsUpdateSubscription`
+ - automod_settings_update
+ - :class:`~models.eventsub_.AutomodSettingsUpdate`
+ * - Automod Terms Update
+ - :meth:`~eventsub.AutomodTermsUpdateSubscription`
+ - automod_terms_update
+ - :class:`~models.eventsub_.AutomodTermsUpdate`
+ * - Channel Update
+ - :meth:`~eventsub.ChannelUpdateSubscription`
+ - channel_update
+ - :class:`~models.eventsub_.ChannelUpdate`
+ * - Channel Follow
+ - :meth:`~eventsub.ChannelFollowSubscription`
+ - follow
+ - :class:`~models.eventsub_.ChannelFollow`
+ * - Channel Ad Break Begin
+ - :meth:`~eventsub.AdBreakBeginSubscription`
+ - ad_break
+ - :class:`~models.eventsub_.ChannelAdBreakBegin`
+ * - Channel Chat Clear
+ - :meth:`~eventsub.ChatClearSubscription`
+ - chat_clear
+ - :class:`~models.eventsub_.ChannelChatClear`
+ * - Channel Chat Clear User Messages
+ - :meth:`~eventsub.ChatClearUserMessagesSubscription`
+ - chat_clear_user
+ - :class:`~models.eventsub_.ChannelChatClearUserMessages`
+ * - Channel Chat Message
+ - :meth:`~eventsub.ChatMessageSubscription`
+ - message
+ - :class:`~models.eventsub_.ChatMessage`
+ * - Channel Chat Message Delete
+ - :meth:`~eventsub.ChatMessageDeleteSubscription`
+ - message_delete
+ - :class:`~models.eventsub_.ChatMessageDelete`
+ * - Channel Chat Notification
+ - :meth:`~eventsub.ChatNotificationSubscription`
+ - chat_notification
+ - :class:`~models.eventsub_.ChatNotification`
+ * - Channel Chat Settings Update
+ - :meth:`~eventsub.ChatSettingsUpdateSubscription`
+ - chat_settings_update
+ - :class:`~models.eventsub_.ChatSettingsUpdate`
+ * - Channel Chat User Message Hold
+ - :meth:`~eventsub.ChatUserMessageHoldSubscription`
+ - chat_user_message_hold
+ - :class:`~models.eventsub_.ChatUserMessageHold`
+ * - Channel Chat User Message Update
+ - :meth:`~eventsub.ChatUserMessageUpdateSubscription`
+ - chat_user_message_update
+ - :class:`~models.eventsub_.ChatUserMessageUpdate`
+ * - Channel Shared Chat Session Begin
+ - :meth:`~eventsub.SharedChatSessionBeginSubscription`
+ - shared_chat_begin
+ - :class:`~models.eventsub_.SharedChatSessionBegin`
+ * - Channel Shared Chat Session Update
+ - :meth:`~eventsub.SharedChatSessionUpdateSubscription`
+ - shared_chat_update
+ - :class:`~models.eventsub_.SharedChatSessionUpdate`
+ * - Channel Shared Chat Session End
+ - :meth:`~eventsub.SharedChatSessionEndSubscription`
+ - shared_chat_end
+ - :class:`~models.eventsub_.SharedChatSessionEnd`
+ * - Channel Subscribe
+ - :meth:`~eventsub.ChannelSubscribeSubscription`
+ - subscription
+ - :class:`~models.eventsub_.ChannelSubscribe`
+ * - Channel Subscription End
+ - :meth:`~eventsub.ChannelSubscriptionEndSubscription`
+ - subscription_end
+ - :class:`~models.eventsub_.ChannelSubscriptionEnd`
+ * - Channel Subscription Gift
+ - :meth:`~eventsub.ChannelSubscriptionGiftSubscription`
+ - subscription_gift
+ - :class:`~models.eventsub_.ChannelSubscriptionGift`
+ * - Channel Subscription Message
+ - :meth:`~eventsub.ChannelSubscribeMessageSubscription`
+ - subscription_message
+ - :class:`~models.eventsub_.ChannelSubscriptionMessage`
+ * - Channel Cheer
+ - :meth:`~eventsub.ChannelCheerSubscription`
+ - cheer
+ - :class:`~models.eventsub_.ChannelCheer`
+ * - Channel Raid
+ - :meth:`~eventsub.ChannelRaidSubscription`
+ - raid
+ - :class:`~models.eventsub_.ChannelRaid`
+ * - Channel Ban
+ - :meth:`~eventsub.ChannelBanSubscription`
+ - ban
+ - :class:`~models.eventsub_.ChannelBan`
+ * - Channel Unban
+ - :meth:`~eventsub.ChannelUnbanSubscription`
+ - unban
+ - :class:`~models.eventsub_.ChannelUnban`
+ * - Channel Unban Request Create
+ - :meth:`~eventsub.ChannelUnbanRequestSubscription`
+ - unban_request
+ - :class:`~models.eventsub_.ChannelUnbanRequest`
+ * - Channel Unban Request Resolve
+ - :meth:`~eventsub.ChannelUnbanRequestResolveSubscription`
+ - unban_request_resolve
+ - :class:`~models.eventsub_.ChannelUnbanRequestResolve`
+ * - Channel Moderate
+ - :meth:`~eventsub.ChannelModerateSubscription`
+ - mod_action
+ - :class:`~models.eventsub_.ChannelModerate`
+ * - Channel Moderate V2
+ - :meth:`~eventsub.ChannelModerateV2Subscription`
+ - mod_action
+ - :class:`~models.eventsub_.ChannelModerate`
+ * - Channel Moderator Add
+ - :meth:`~eventsub.ChannelModeratorAddSubscription`
+ - moderator_add
+ - :class:`~models.eventsub_.ChannelModeratorAdd`
+ * - Channel Moderator Remove
+ - :meth:`~eventsub.ChannelModeratorRemoveSubscription`
+ - moderator_remove
+ - :class:`~models.eventsub_.ChannelModeratorRemove`
+ * - Channel Points Automatic Reward Redemption
+ - :meth:`~eventsub.ChannelPointsAutoRedeemSubscription`
+ - automatic_redemption_add
+ - :class:`~models.eventsub_.ChannelPointsAutoRedeemAdd`
+ * - Channel Points Custom Reward Add
+ - :meth:`~eventsub.ChannelPointsRewardAddSubscription`
+ - custom_reward_add
+ - :class:`~models.eventsub_.ChannelPointsRewardAdd`
+ * - Channel Points Custom Reward Update
+ - :meth:`~eventsub.ChannelPointsRewardUpdateSubscription`
+ - custom_reward_update
+ - :class:`~models.eventsub_.ChannelPointsRewardUpdate`
+ * - Channel Points Custom Reward Remove
+ - :meth:`~eventsub.ChannelPointsRewardRemoveSubscription`
+ - custom_reward_remove
+ - :class:`~models.eventsub_.ChannelPointsRewardRemove`
+ * - Channel Points Custom Reward Redemption Add
+ - :meth:`~eventsub.ChannelPointsRedeemAddSubscription`
+ - custom_redemption_add
+ - :class:`~models.eventsub_.ChannelPointsRedemptionAdd`
+ * - Channel Points Custom Reward Redemption Update
+ - :meth:`~eventsub.ChannelPointsRedeemUpdateSubscription`
+ - custom_redemption_update
+ - :class:`~models.eventsub_.ChannelPointsRedemptionUpdate`
+ * - Channel Poll Begin
+ - :meth:`~eventsub.ChannelPollBeginSubscription`
+ - poll_begin
+ - :class:`~models.eventsub_.ChannelPollBegin`
+ * - Channel Poll Progress
+ - :meth:`~eventsub.ChannelPollProgressSubscription`
+ - poll_progress
+ - :class:`~models.eventsub_.ChannelPollProgress`
+ * - Channel Poll End
+ - :meth:`~eventsub.ChannelPollEndSubscription`
+ - poll_end
+ - :class:`~models.eventsub_.ChannelPollEnd`
+ * - Channel Prediction Begin
+ - :meth:`~eventsub.ChannelPredictionBeginSubscription`
+ - prediction_begin
+ - :class:`~models.eventsub_.ChannelPredictionBegin`
+ * - Channel Prediction Progress
+ - :meth:`~eventsub.ChannelPredictionProgressSubscription`
+ - prediction_progress
+ - :class:`~models.eventsub_.ChannelPredictionProgress`
+ * - Channel Prediction Lock
+ - :meth:`~eventsub.ChannelPredictionLockSubscription`
+ - prediction_lock
+ - :class:`~models.eventsub_.ChannelPredictionLock`
+ * - Channel Prediction End
+ - :meth:`~eventsub.ChannelPredictionEndSubscription`
+ - prediction_end
+ - :class:`~models.eventsub_.ChannelPredictionEnd`
+ * - Channel Suspicious User Message
+ - :meth:`~eventsub.SuspiciousUserMessageSubscription`
+ - suspicious_user_message
+ - :class:`~models.eventsub_.SuspiciousUserMessage`
+ * - Channel Suspicious User Update
+ - :meth:`~eventsub.SuspiciousUserUpdateSubscription`
+ - suspicious_user_update
+ - :class:`~models.eventsub_.SuspiciousUserUpdate`
+ * - Channel VIP Add
+ - :meth:`~eventsub.ChannelVIPAddSubscription`
+ - vip_add
+ - :class:`~models.eventsub_.ChannelVIPAdd`
+ * - Channel VIP Remove
+ - :meth:`~eventsub.ChannelVIPRemoveSubscription`
+ - vip_remove
+ - :class:`~models.eventsub_.ChannelVIPRemove`
+ * - Channel Warning Acknowledgement
+ - :meth:`~eventsub.ChannelWarningAcknowledgementSubscription`
+ - warning_acknowledge
+ - :class:`~models.eventsub_.ChannelWarningAcknowledge`
+ * - Channel Warning Send
+ - :meth:`~eventsub.ChannelWarningSendSubscription`
+ - warning_send
+ - :class:`~models.eventsub_.ChannelWarningSend`
+ * - Charity Donation
+ - :meth:`~eventsub.CharityDonationSubscription`
+ - charity_campaign_donate
+ - :class:`~models.eventsub_.CharityCampaignDonation`
+ * - Charity Campaign Start
+ - :meth:`~eventsub.CharityCampaignStartSubscription`
+ - charity_campaign_start
+ - :class:`~models.eventsub_.CharityCampaignStart`
+ * - Charity Campaign Progress
+ - :meth:`~eventsub.CharityCampaignProgressSubscription`
+ - charity_campaign_progress
+ - :class:`~models.eventsub_.CharityCampaignProgress`
+ * - Charity Campaign Stop
+ - :meth:`~eventsub.CharityCampaignStopSubscription`
+ - charity_campaign_stop
+ - :class:`~models.eventsub_.CharityCampaignStop`
+ * - Goal Begin
+ - :meth:`~eventsub.GoalBeginSubscription`
+ - goal_begin
+ - :class:`~models.eventsub_.GoalBegin`
+ * - Goal Progress
+ - :meth:`~eventsub.GoalProgressSubscription`
+ - goal_progress
+ - :class:`~models.eventsub_.GoalProgress`
+ * - Goal End
+ - :meth:`~eventsub.GoalEndSubscription`
+ - goal_end
+ - :class:`~models.eventsub_.GoalEnd`
+ * - Hype Train Begin
+ - :meth:`~eventsub.HypeTrainBeginSubscription`
+ - hype_train
+ - :class:`~models.eventsub_.HypeTrainBegin`
+ * - Hype Train Progress
+ - :meth:`~eventsub.HypeTrainProgressSubscription`
+ - hype_train_progress
+ - :class:`~models.eventsub_.HypeTrainProgress`
+ * - Hype Train End
+ - :meth:`~eventsub.HypeTrainEndSubscription`
+ - hype_train_end
+ - :class:`~models.eventsub_.HypeTrainEnd`
+ * - Shield Mode Begin
+ - :meth:`~eventsub.ShieldModeBeginSubscription`
+ - shield_mode_begin
+ - :class:`~models.eventsub_.ShieldModeBegin`
+ * - Shield Mode End
+ - :meth:`~eventsub.ShieldModeEndSubscription`
+ - shield_mode_end
+ - :class:`~models.eventsub_.ShieldModeEnd`
+ * - Shoutout Create
+ - :meth:`~eventsub.ShoutoutCreateSubscription`
+ - shoutout_create
+ - :class:`~models.eventsub_.ShoutoutCreate`
+ * - Shoutout Received
+ - :meth:`~eventsub.ShoutoutReceiveSubscription`
+ - shoutout_receive
+ - :class:`~models.eventsub_.ShoutoutReceive`
+ * - Stream Online
+ - :meth:`~eventsub.StreamOnlineSubscription`
+ - :func:`~twitchio.event_stream_online()`
+ - :class:`~models.eventsub_.StreamOnline`
+ * - Stream Offline
+ - :meth:`~eventsub.StreamOfflineSubscription`
+ - :func:`~twitchio.event_stream_offline()`
+ - :class:`~models.eventsub_.StreamOffline`
+ * - User Authorization Grant
+ - :meth:`~eventsub.UserAuthorizationGrantSubscription`
+ - user_authorization_grant
+ - :class:`~models.eventsub_.UserAuthorizationGrant`
+ * - User Authorization Revoke
+ - :meth:`~eventsub.UserAuthorizationRevokeSubscription`
+ - user_authorization_revoke
+ - :class:`~models.eventsub_.UserAuthorizationRevoke`
+ * - User Update
+ - :meth:`~eventsub.UserUpdateSubscription`
+ - user_update
+ - :class:`~models.eventsub_.UserUpdate`
+ * - Whisper Received
+ - :meth:`~eventsub.WhisperReceivedSubscription`
+ - message_whisper
+ - :class:`~models.eventsub_.Whisper`
+
+
+Client Events
+~~~~~~~~~~~~~
+
+.. py:function:: event_ready() -> None
+ :async:
+
+ Event dispatched when the :class:`~.Client` is ready and has completed login.
+
+.. py:function:: event_error(payload: twitchio.EventErrorPayload) -> None
+ :async:
+
+ Event dispatched when an exception is raised inside of a dispatched event.
+
+ :param twitchio.EventErrorPayload payload: The payload containing information about the event and exception raised.
+
+.. py:function:: event_oauth_authorized(payload: twitchio.authentication.UserTokenPayload) -> None
+ :async:
+
+ Event dispatched when a user authorizes your Client-ID via Twitch OAuth on a built-in web adapter.
+
+ The default behaviour of this event is to add the authorized token to the client.
+ See: :class:`~twitchio.Client.add_token` for more details.
+
+ :param UserTokenPayload payload: The payload containing token information.
+
+
+Commands Events
+~~~~~~~~~~~~~~~
+
+.. py:function:: event_command_invoked(ctx: twitchio.ext.commands.Context) -> None
+ :async:
+
+ Event dispatched when a :class:`~twitchio.ext.commands.Command` is invoked.
+
+ :param twitchio.ext.commands.Context ctx: The context object that invoked the command.
+
+.. py:function:: event_command_completed(ctx: twitchio.ext.commands.Context) -> None
+ :async:
+
+ Event dispatched when a :class:`~twitchio.ext.commands.Command` has completed invocation.
+
+ :param twitchio.ext.commands.Context ctx: The context object that invoked the command.
+
+.. py:function:: event_command_error(payload: twitchio.ext.commands.CommandErrorPayload) -> None
+ :async:
+
+ Event dispatched when a :class:`~twitchio.ext.commands.Command` encounters an error during invocation.
+
+ :param twitchio.ext.commands.CommandErrorPayload payload: The error payload containing context and the exception raised.
+
+
+EventSub Events
+~~~~~~~~~~~~~~~
+
+Automod
+-------
+
+.. py:function:: event_automod_message_hold(payload: twitchio.AutomodMessageHold) -> None
+ :async:
+
+ Event dispatched when a message is held by Automod and needs review.
+
+ Corresponds to the Twitch EventSub subscriptions :es-docs:`Automod Message Hold ` and
+ :es-docs:`Automod Message Hold V2 `.
+
+ You must subscribe to EventSub with :class:`~twitchio.eventsub.AutomodMessageHoldSubscription` or
+ :class:`~twitchio.eventsub.AutomodMessageHoldV2Subscription` for each required stream to receive this event.
+
+ :param twitchio.AutomodMessageHold payload: The EventSub payload received for this event.
+
+.. py:function:: event_automod_message_update(payload: twitchio.AutomodMessageUpdate) -> None
+ :async:
+
+ Event dispatched when a message held by Automod status changes.
+
+ Corresponds to the Twitch EventSub subscriptions :es-docs:`Automod Message Update ` and
+ :es-docs:`Automod Message Update V2 `.
+
+ You must subscribe to EventSub with :class:`~twitchio.eventsub.AutomodMessageUpdateSubscription` or
+ :class:`~twitchio.eventsub.AutomodMessageUpdateV2Subscription` for each required stream to receive this event.
+
+ :param twitchio.AutomodMessageUpdate payload: The EventSub payload received for this event.
+
+Streams
+-------
+
+.. py:function:: event_stream_online(payload: twitchio.StreamOnline) -> None
+ :async:
+
+ Event dispatched when a stream comes online.
+
+ Corresponds to the Twitch EventSub subscription :es-docs:`Stream Online `.
+
+ You must subscribe to EventSub with :class:`~twitchio.eventsub.StreamOnlineSubscription` for each required stream
+ to receive this event.
+
+ :param twitchio.StreamOnline payload: The Stream Online payload for this event.
+
+.. py:function:: event_stream_offline(payload: twitchio.StreamOffline) -> None
+ :async:
+
+ Event dispatched when a stream goes offline.
+
+ Corresponds to the Twitch EventSub subscription :es-docs:`Stream Offline `.
+
+ You must subscribe to EventSub with :class:`~twitchio.eventsub.StreamOfflineSubscription` for each required stream
+ to receive this event.
+
+ :param twitchio.StreamOffline payload: The Stream Offline payload for this event.
+
+Payloads
+~~~~~~~~
+
+.. attributetable:: twitchio.EventErrorPayload
+
+.. autoclass:: twitchio.EventErrorPayload()
+ :members:
\ No newline at end of file
diff --git a/docs/references/eventsub_models.rst b/docs/references/eventsub_models.rst
new file mode 100644
index 00000000..0d9cb5c2
--- /dev/null
+++ b/docs/references/eventsub_models.rst
@@ -0,0 +1,527 @@
+.. currentmodule:: twitchio
+
+
+Eventsub
+#################
+
+.. attributetable:: twitchio.AutomodMessageHold
+
+.. autoclass:: twitchio.AutomodMessageHold()
+ :members:
+
+.. attributetable:: twitchio.AutomodBlockedTerm
+
+.. autoclass:: twitchio.AutomodBlockedTerm()
+ :members:
+
+.. attributetable:: twitchio.Boundary
+
+.. autoclass:: twitchio.Boundary()
+
+.. attributetable:: twitchio.AutomodMessageUpdate
+
+.. autoclass:: twitchio.AutomodMessageUpdate()
+ :members:
+
+.. attributetable:: twitchio.AutomodSettingsUpdate
+
+.. autoclass:: twitchio.AutomodSettingsUpdate()
+ :members:
+
+.. attributetable:: twitchio.AutomodTermsUpdate
+
+.. autoclass:: twitchio.AutomodTermsUpdate()
+ :members:
+
+.. attributetable:: twitchio.ChannelUpdate
+
+.. autoclass:: twitchio.ChannelUpdate()
+ :members:
+
+.. attributetable:: twitchio.ChannelFollow
+
+.. autoclass:: twitchio.ChannelFollow()
+ :members:
+
+.. attributetable:: twitchio.ChannelAdBreakBegin
+
+.. autoclass:: twitchio.ChannelAdBreakBegin()
+ :members:
+
+.. attributetable:: twitchio.ChannelChatClear
+
+.. autoclass:: twitchio.ChannelChatClear()
+ :members:
+
+.. attributetable:: twitchio.ChannelChatClearUserMessages
+
+.. autoclass:: twitchio.ChannelChatClearUserMessages()
+ :members:
+
+.. attributetable:: twitchio.BaseChatMessage
+
+.. autoclass:: twitchio.BaseChatMessage()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageReply
+
+.. autoclass:: twitchio.ChatMessageReply()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageCheer
+
+.. autoclass:: twitchio.ChatMessageCheer()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageBadge
+
+.. autoclass:: twitchio.ChatMessageBadge()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageEmote
+
+.. autoclass:: twitchio.ChatMessageEmote()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageCheermote
+
+.. autoclass:: twitchio.ChatMessageCheermote()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageFragment
+
+.. autoclass:: twitchio.ChatMessageFragment()
+ :members:
+
+.. attributetable:: twitchio.ChatMessage
+
+.. autoclass:: twitchio.ChatMessage()
+ :members:
+
+.. attributetable:: twitchio.ChatSub
+
+.. autoclass:: twitchio.ChatSub()
+ :members:
+
+.. attributetable:: twitchio.ChatResub
+
+.. autoclass:: twitchio.ChatResub()
+ :members:
+
+.. attributetable:: twitchio.ChatSubGift
+
+.. autoclass:: twitchio.ChatSubGift()
+ :members:
+
+.. attributetable:: twitchio.ChatCommunitySubGift
+
+.. autoclass:: twitchio.ChatCommunitySubGift()
+ :members:
+
+.. attributetable:: twitchio.ChatGiftPaidUpgrade
+
+.. autoclass:: twitchio.ChatGiftPaidUpgrade()
+ :members:
+
+.. attributetable:: twitchio.ChatPrimePaidUpgrade
+
+.. autoclass:: twitchio.ChatPrimePaidUpgrade()
+ :members:
+
+.. attributetable:: twitchio.ChatRaid
+
+.. autoclass:: twitchio.ChatRaid()
+ :members:
+
+.. attributetable:: twitchio.ChatPayItForward
+
+.. autoclass:: twitchio.ChatPayItForward()
+ :members:
+
+.. attributetable:: twitchio.ChatAnnouncement
+
+.. autoclass:: twitchio.ChatAnnouncement()
+ :members:
+
+.. attributetable:: twitchio.ChatBitsBadgeTier
+
+.. autoclass:: twitchio.ChatBitsBadgeTier()
+ :members:
+
+.. attributetable:: twitchio.BaseCharityCampaign
+
+.. autoclass:: twitchio.BaseCharityCampaign()
+ :members:
+
+.. attributetable:: twitchio.ChatCharityDonation
+
+.. autoclass:: twitchio.ChatCharityDonation()
+ :members:
+
+.. attributetable:: twitchio.ChatNotification
+
+.. autoclass:: twitchio.ChatNotification()
+ :members:
+
+.. attributetable:: twitchio.ChatMessageDelete
+
+.. autoclass:: twitchio.ChatMessageDelete()
+ :members:
+
+.. attributetable:: twitchio.ChatSettingsUpdate
+
+.. autoclass:: twitchio.ChatSettingsUpdate()
+ :members:
+
+.. attributetable:: twitchio.ChatUserMessageHold
+
+.. autoclass:: twitchio.ChatUserMessageHold()
+ :members:
+
+.. attributetable:: twitchio.ChatUserMessageUpdate
+
+.. autoclass:: twitchio.ChatUserMessageUpdate()
+ :members:
+
+.. attributetable:: twitchio.SharedChatSessionBegin
+
+.. autoclass:: twitchio.SharedChatSessionBegin()
+ :members:
+
+.. attributetable:: twitchio.SharedChatSessionUpdate
+
+.. autoclass:: twitchio.SharedChatSessionUpdate()
+ :members:
+
+.. attributetable:: twitchio.SharedChatSessionEnd
+
+.. autoclass:: twitchio.SharedChatSessionEnd()
+ :members:
+
+.. attributetable:: twitchio.ChannelSubscribe
+
+.. autoclass:: twitchio.ChannelSubscribe()
+ :members:
+
+.. attributetable:: twitchio.ChannelSubscriptionEnd
+
+.. autoclass:: twitchio.ChannelSubscriptionEnd()
+ :members:
+
+.. attributetable:: twitchio.ChannelSubscriptionGift
+
+.. autoclass:: twitchio.ChannelSubscriptionGift()
+ :members:
+
+.. attributetable:: twitchio.SubscribeEmote
+
+.. autoclass:: twitchio.SubscribeEmote()
+ :members:
+
+.. attributetable:: twitchio.ChannelSubscriptionMessage
+
+.. autoclass:: twitchio.ChannelSubscriptionMessage()
+ :members:
+
+.. attributetable:: twitchio.ChannelCheer
+
+.. autoclass:: twitchio.ChannelCheer()
+ :members:
+
+.. attributetable:: twitchio.ChannelRaid
+
+.. autoclass:: twitchio.ChannelRaid()
+ :members:
+
+.. attributetable:: twitchio.ChannelBan
+
+.. autoclass:: twitchio.ChannelBan()
+ :members:
+
+.. attributetable:: twitchio.ChannelUnban
+
+.. autoclass:: twitchio.ChannelUnban()
+ :members:
+
+.. attributetable:: twitchio.ChannelUnbanRequest
+
+.. autoclass:: twitchio.ChannelUnbanRequest()
+ :members:
+
+.. attributetable:: twitchio.ChannelUnbanRequestResolve
+
+.. autoclass:: twitchio.ChannelUnbanRequestResolve()
+ :members:
+
+.. attributetable:: twitchio.ModerateFollowers
+
+.. autoclass:: twitchio.ModerateFollowers()
+ :members:
+
+.. attributetable:: twitchio.ModerateBan
+
+.. autoclass:: twitchio.ModerateBan()
+ :members:
+
+.. attributetable:: twitchio.ModerateTimeout
+
+.. autoclass:: twitchio.ModerateTimeout()
+ :members:
+
+.. attributetable:: twitchio.ModerateSlow
+
+.. autoclass:: twitchio.ModerateSlow()
+ :members:
+
+.. attributetable:: twitchio.ModerateRaid
+
+.. autoclass:: twitchio.ModerateRaid()
+ :members:
+
+.. attributetable:: twitchio.ModerateDelete
+
+.. autoclass:: twitchio.ModerateDelete()
+ :members:
+
+.. attributetable:: twitchio.ModerateAutomodTerms
+
+.. autoclass:: twitchio.ModerateAutomodTerms()
+ :members:
+
+.. attributetable:: twitchio.ModerateUnbanRequest
+
+.. autoclass:: twitchio.ModerateUnbanRequest()
+ :members:
+
+.. attributetable:: twitchio.ModerateWarn
+
+.. autoclass:: twitchio.ModerateWarn()
+ :members:
+
+.. attributetable:: twitchio.ChannelModerate
+
+.. autoclass:: twitchio.ChannelModerate()
+ :members:
+
+.. attributetable:: twitchio.ChannelModeratorAdd
+
+.. autoclass:: twitchio.ChannelModeratorAdd()
+ :members:
+
+.. attributetable:: twitchio.ChannelModeratorRemove
+
+.. autoclass:: twitchio.ChannelModeratorRemove()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsEmote
+
+.. autoclass:: twitchio.ChannelPointsEmote()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsAutoRedeemAdd
+
+.. autoclass:: twitchio.ChannelPointsAutoRedeemAdd()
+ :members:
+
+.. attributetable:: twitchio.CooldownSettings
+
+.. autoclass:: twitchio.CooldownSettings()
+
+.. attributetable:: twitchio.ChannelPointsReward
+
+.. autoclass:: twitchio.ChannelPointsReward()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsRewardAdd
+
+.. autoclass:: twitchio.ChannelPointsRewardAdd()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsRewardUpdate
+
+.. autoclass:: twitchio.ChannelPointsRewardUpdate()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsRewardRemove
+
+.. autoclass:: twitchio.ChannelPointsRewardRemove()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsRedemptionAdd
+
+.. autoclass:: twitchio.ChannelPointsRedemptionAdd()
+ :members:
+
+.. attributetable:: twitchio.ChannelPointsRedemptionUpdate
+
+.. autoclass:: twitchio.ChannelPointsRedemptionUpdate()
+ :members:
+
+.. attributetable:: twitchio.PollVoting
+
+.. autoclass:: twitchio.PollVoting()
+
+.. attributetable:: twitchio.ChannelPollBegin
+
+.. autoclass:: twitchio.ChannelPollBegin()
+ :members:
+
+.. attributetable:: twitchio.ChannelPollProgress
+
+.. autoclass:: twitchio.ChannelPollProgress()
+ :members:
+
+.. attributetable:: twitchio.ChannelPollEnd
+
+.. autoclass:: twitchio.ChannelPollEnd()
+ :members:
+
+.. attributetable:: twitchio.ChannelPredictionBegin
+
+.. autoclass:: twitchio.ChannelPredictionBegin()
+ :members:
+
+.. attributetable:: twitchio.ChannelPredictionProgress
+
+.. autoclass:: twitchio.ChannelPredictionProgress()
+ :members:
+
+.. attributetable:: twitchio.ChannelPredictionLock
+
+.. autoclass:: twitchio.ChannelPredictionLock()
+ :members:
+
+.. attributetable:: twitchio.ChannelPredictionEnd
+
+.. autoclass:: twitchio.ChannelPredictionEnd()
+ :members:
+
+.. attributetable:: twitchio.SuspiciousUserUpdate
+
+.. autoclass:: twitchio.SuspiciousUserUpdate()
+ :members:
+
+.. attributetable:: twitchio.SuspiciousUserMessage
+
+.. autoclass:: twitchio.SuspiciousUserMessage()
+ :members:
+
+.. attributetable:: twitchio.ChannelVIPAdd
+
+.. autoclass:: twitchio.ChannelVIPAdd()
+ :members:
+
+.. attributetable:: twitchio.ChannelVIPRemove
+
+.. autoclass:: twitchio.ChannelVIPRemove()
+ :members:
+
+.. attributetable:: twitchio.ChannelWarningAcknowledge
+
+.. autoclass:: twitchio.ChannelWarningAcknowledge()
+ :members:
+
+.. attributetable:: twitchio.ChannelWarningSend
+
+.. autoclass:: twitchio.ChannelWarningSend()
+ :members:
+
+.. attributetable:: twitchio.CharityCampaignDonation
+
+.. autoclass:: twitchio.CharityCampaignDonation()
+ :members:
+
+.. attributetable:: twitchio.CharityCampaignStart
+
+.. autoclass:: twitchio.CharityCampaignStart()
+ :members:
+
+.. attributetable:: twitchio.CharityCampaignProgress
+
+.. autoclass:: twitchio.CharityCampaignProgress()
+ :members:
+
+.. attributetable:: twitchio.CharityCampaignStop
+
+.. autoclass:: twitchio.CharityCampaignStop()
+ :members:
+
+.. attributetable:: twitchio.GoalBegin
+
+.. autoclass:: twitchio.GoalBegin()
+ :members:
+
+.. attributetable:: twitchio.GoalProgress
+
+.. autoclass:: twitchio.GoalProgress()
+ :members:
+
+.. attributetable:: twitchio.GoalEnd
+
+.. autoclass:: twitchio.GoalEnd()
+ :members:
+
+.. attributetable:: twitchio.HypeTrainBegin
+
+.. autoclass:: twitchio.HypeTrainBegin()
+ :members:
+
+.. attributetable:: twitchio.HypeTrainProgress
+
+.. autoclass:: twitchio.HypeTrainProgress()
+ :members:
+
+.. attributetable:: twitchio.HypeTrainEnd
+
+.. autoclass:: twitchio.HypeTrainEnd()
+ :members:
+
+.. attributetable:: twitchio.ShieldModeBegin
+
+.. autoclass:: twitchio.ShieldModeBegin()
+ :members:
+
+.. attributetable:: twitchio.ShieldModeEnd
+
+.. autoclass:: twitchio.ShieldModeEnd()
+ :members:
+
+.. attributetable:: twitchio.ShoutoutCreate
+
+.. autoclass:: twitchio.ShoutoutCreate()
+ :members:
+
+.. attributetable:: twitchio.ShoutoutReceive
+
+.. autoclass:: twitchio.ShoutoutReceive()
+ :members:
+
+.. attributetable:: twitchio.StreamOnline
+
+.. autoclass:: twitchio.StreamOnline()
+ :members:
+
+.. attributetable:: twitchio.StreamOffline
+
+.. autoclass:: twitchio.StreamOffline()
+ :members:
+
+.. attributetable:: twitchio.UserAuthorizationGrant
+
+.. autoclass:: twitchio.UserAuthorizationGrant()
+ :members:
+
+.. attributetable:: twitchio.UserAuthorizationRevoke
+
+.. autoclass:: twitchio.UserAuthorizationRevoke()
+ :members:
+
+.. attributetable:: twitchio.UserUpdate
+
+.. autoclass:: twitchio.UserUpdate()
+ :members:
+
+.. attributetable:: twitchio.Whisper
+
+.. autoclass:: twitchio.Whisper()
+ :members:
diff --git a/docs/references/eventsub_subscriptions.rst b/docs/references/eventsub_subscriptions.rst
new file mode 100644
index 00000000..a7c37863
--- /dev/null
+++ b/docs/references/eventsub_subscriptions.rst
@@ -0,0 +1,370 @@
+.. currentmodule:: twitchio.eventsub
+
+
+Eventsub Subscriptions
+#######################
+
+.. attributetable:: AutomodMessageHoldSubscription
+
+.. autoclass:: AutomodMessageHoldSubscription
+ :members:
+
+.. attributetable:: AutomodMessageHoldV2Subscription
+
+.. autoclass:: AutomodMessageHoldV2Subscription
+ :members:
+
+.. attributetable:: AutomodMessageUpdateSubscription
+
+.. autoclass:: AutomodMessageUpdateSubscription
+ :members:
+
+.. attributetable:: AutomodMessageUpdateV2Subscription
+
+.. autoclass:: AutomodMessageUpdateV2Subscription
+ :members:
+
+.. attributetable:: AutomodSettingsUpdateSubscription
+
+.. autoclass:: AutomodSettingsUpdateSubscription
+ :members:
+
+.. attributetable:: AutomodTermsUpdateSubscription
+
+.. autoclass:: AutomodTermsUpdateSubscription
+ :members:
+
+.. attributetable:: ChannelUpdateSubscription
+
+.. autoclass:: ChannelUpdateSubscription
+ :members:
+
+.. attributetable:: ChannelFollowSubscription
+
+.. autoclass:: ChannelFollowSubscription
+ :members:
+
+.. attributetable:: AdBreakBeginSubscription
+
+.. autoclass:: AdBreakBeginSubscription
+ :members:
+
+.. attributetable:: ChatClearSubscription
+
+.. autoclass:: ChatClearSubscription
+ :members:
+
+.. attributetable:: ChatClearUserMessagesSubscription
+
+.. autoclass:: ChatClearUserMessagesSubscription
+ :members:
+
+.. attributetable:: ChatMessageSubscription
+
+.. autoclass:: ChatMessageSubscription
+ :members:
+
+.. attributetable:: ChatNotificationSubscription
+
+.. autoclass:: ChatNotificationSubscription
+ :members:
+
+.. attributetable:: ChatMessageDeleteSubscription
+
+.. autoclass:: ChatMessageDeleteSubscription
+ :members:
+
+.. attributetable:: ChatSettingsUpdateSubscription
+
+.. autoclass:: ChatSettingsUpdateSubscription
+ :members:
+
+.. attributetable:: ChatUserMessageHoldSubscription
+
+.. autoclass:: ChatUserMessageHoldSubscription
+ :members:
+
+.. attributetable:: ChatUserMessageUpdateSubscription
+
+.. autoclass:: ChatUserMessageUpdateSubscription
+ :members:
+
+.. attributetable:: SharedChatSessionBeginSubscription
+
+.. autoclass:: SharedChatSessionBeginSubscription
+ :members:
+
+.. attributetable:: SharedChatSessionUpdateSubscription
+
+.. autoclass:: SharedChatSessionUpdateSubscription
+ :members:
+
+.. attributetable:: SharedChatSessionEndSubscription
+
+.. autoclass:: SharedChatSessionEndSubscription
+ :members:
+
+.. attributetable:: ChannelSubscribeSubscription
+
+.. autoclass:: ChannelSubscribeSubscription
+ :members:
+
+.. attributetable:: ChannelSubscriptionEndSubscription
+
+.. autoclass:: ChannelSubscriptionEndSubscription
+ :members:
+
+.. attributetable:: ChannelSubscriptionGiftSubscription
+
+.. autoclass:: ChannelSubscriptionGiftSubscription
+ :members:
+
+.. attributetable:: ChannelSubscribeMessageSubscription
+
+.. autoclass:: ChannelSubscribeMessageSubscription
+ :members:
+
+.. attributetable:: ChannelCheerSubscription
+
+.. autoclass:: ChannelCheerSubscription
+ :members:
+
+.. attributetable:: ChannelRaidSubscription
+
+.. autoclass:: ChannelRaidSubscription
+ :members:
+
+.. attributetable:: ChannelBanSubscription
+
+.. autoclass:: ChannelBanSubscription
+ :members:
+
+.. attributetable:: ChannelUnbanSubscription
+
+.. autoclass:: ChannelUnbanSubscription
+ :members:
+
+.. attributetable:: ChannelUnbanRequestSubscription
+
+.. autoclass:: ChannelUnbanRequestSubscription
+ :members:
+
+.. attributetable:: ChannelUnbanRequestResolveSubscription
+
+.. autoclass:: ChannelUnbanRequestResolveSubscription
+ :members:
+
+.. attributetable:: ChannelModerateSubscription
+
+.. autoclass:: ChannelModerateSubscription
+ :members:
+
+.. attributetable:: ChannelModerateV2Subscription
+
+.. autoclass:: ChannelModerateV2Subscription
+ :members:
+
+.. attributetable:: ChannelModeratorAddSubscription
+
+.. autoclass:: ChannelModeratorAddSubscription
+ :members:
+
+.. attributetable:: ChannelModeratorRemoveSubscription
+
+.. autoclass:: ChannelModeratorRemoveSubscription
+ :members:
+
+.. attributetable:: ChannelPointsAutoRedeemSubscription
+
+.. autoclass:: ChannelPointsAutoRedeemSubscription
+ :members:
+
+.. attributetable:: ChannelPointsRewardAddSubscription
+
+.. autoclass:: ChannelPointsRewardAddSubscription
+ :members:
+
+.. attributetable:: ChannelPointsRewardUpdateSubscription
+
+.. autoclass:: ChannelPointsRewardUpdateSubscription
+ :members:
+
+.. attributetable:: ChannelPointsRewardRemoveSubscription
+
+.. autoclass:: ChannelPointsRewardRemoveSubscription
+ :members:
+
+.. attributetable:: ChannelPointsRedeemAddSubscription
+
+.. autoclass:: ChannelPointsRedeemAddSubscription
+ :members:
+
+.. attributetable:: ChannelPointsRedeemUpdateSubscription
+
+.. autoclass:: ChannelPointsRedeemUpdateSubscription
+ :members:
+
+.. attributetable:: ChannelPollBeginSubscription
+
+.. autoclass:: ChannelPollBeginSubscription
+ :members:
+
+.. attributetable:: ChannelPollProgressSubscription
+
+.. autoclass:: ChannelPollProgressSubscription
+ :members:
+
+.. attributetable:: ChannelPollEndSubscription
+
+.. autoclass:: ChannelPollEndSubscription
+ :members:
+
+.. attributetable:: ChannelPredictionBeginSubscription
+
+.. autoclass:: ChannelPredictionBeginSubscription
+ :members:
+
+.. attributetable:: ChannelPredictionLockSubscription
+
+.. autoclass:: ChannelPredictionLockSubscription
+ :members:
+
+.. attributetable:: ChannelPredictionProgressSubscription
+
+.. autoclass:: ChannelPredictionProgressSubscription
+ :members:
+
+.. attributetable:: ChannelPredictionEndSubscription
+
+.. autoclass:: ChannelPredictionEndSubscription
+ :members:
+
+.. attributetable:: SuspiciousUserUpdateSubscription
+
+.. autoclass:: SuspiciousUserUpdateSubscription
+ :members:
+
+.. attributetable:: SuspiciousUserMessageSubscription
+
+.. autoclass:: SuspiciousUserMessageSubscription
+ :members:
+
+.. attributetable:: ChannelVIPAddSubscription
+
+.. autoclass:: ChannelVIPAddSubscription
+ :members:
+
+.. attributetable:: ChannelVIPRemoveSubscription
+
+.. autoclass:: ChannelVIPRemoveSubscription
+ :members:
+
+.. attributetable:: ChannelWarningAcknowledgementSubscription
+
+.. autoclass:: ChannelWarningAcknowledgementSubscription
+ :members:
+
+.. attributetable:: ChannelWarningSendSubscription
+
+.. autoclass:: ChannelWarningSendSubscription
+ :members:
+
+.. attributetable:: CharityDonationSubscription
+
+.. autoclass:: CharityDonationSubscription
+ :members:
+
+.. attributetable:: CharityCampaignStartSubscription
+
+.. autoclass:: CharityCampaignStartSubscription
+ :members:
+
+.. attributetable:: CharityCampaignProgressSubscription
+
+.. autoclass:: CharityCampaignProgressSubscription
+ :members:
+
+.. attributetable:: CharityCampaignStopSubscription
+
+.. autoclass:: CharityCampaignStopSubscription
+ :members:
+
+.. attributetable:: GoalBeginSubscription
+
+.. autoclass:: GoalBeginSubscription
+ :members:
+
+.. attributetable:: GoalProgressSubscription
+
+.. autoclass:: GoalProgressSubscription
+ :members:
+
+.. attributetable:: GoalEndSubscription
+
+.. autoclass:: GoalEndSubscription
+ :members:
+
+.. attributetable:: HypeTrainBeginSubscription
+
+.. autoclass:: HypeTrainBeginSubscription
+ :members:
+
+.. attributetable:: HypeTrainProgressSubscription
+
+.. autoclass:: HypeTrainProgressSubscription
+ :members:
+
+.. attributetable:: HypeTrainEndSubscription
+
+.. autoclass:: HypeTrainEndSubscription
+ :members:
+
+.. attributetable:: ShieldModeBeginSubscription
+
+.. autoclass:: ShieldModeBeginSubscription
+ :members:
+
+.. attributetable:: ShieldModeEndSubscription
+
+.. autoclass:: ShieldModeEndSubscription
+ :members:
+
+.. attributetable:: ShoutoutCreateSubscription
+
+.. autoclass:: ShoutoutCreateSubscription
+ :members:
+
+.. attributetable:: ShoutoutReceiveSubscription
+
+.. autoclass:: ShoutoutReceiveSubscription
+ :members:
+
+.. attributetable:: StreamOnlineSubscription
+
+.. autoclass:: StreamOnlineSubscription
+ :members:
+
+.. attributetable:: StreamOfflineSubscription
+
+.. autoclass:: StreamOfflineSubscription
+ :members:
+
+.. attributetable:: UserAuthorizationGrantSubscription
+
+.. autoclass:: UserAuthorizationGrantSubscription
+ :members:
+
+.. attributetable:: UserAuthorizationRevokeSubscription
+
+.. autoclass:: UserAuthorizationRevokeSubscription
+ :members:
+
+.. attributetable:: UserUpdateSubscription
+
+.. autoclass:: UserUpdateSubscription
+ :members:
+
+.. attributetable:: WhisperReceivedSubscription
+
+.. autoclass:: WhisperReceivedSubscription
+ :members:
\ No newline at end of file
diff --git a/docs/references/exceptions.rst b/docs/references/exceptions.rst
new file mode 100644
index 00000000..3873199c
--- /dev/null
+++ b/docs/references/exceptions.rst
@@ -0,0 +1,27 @@
+.. currentmodule:: twitchio
+
+
+Exceptions
+----------
+
+.. autoclass:: twitchio.TwitchioException()
+
+.. autoclass:: twitchio.HTTPException()
+ :members:
+
+.. autoclass:: twitchio.InvalidTokenException()
+ :members:
+
+.. autoclass:: twitchio.MessageRejectedError()
+ :members:
+
+
+Exception Hierarchy
+~~~~~~~~~~~~~~~~~~~
+
+.. exception_hierarchy::
+
+ - :exc:`TwitchioException`
+ - :exc:`HTTPException`
+ - :exc:`InvalidTokenException`
+ - :exc:`MessageRejectedError`
\ No newline at end of file
diff --git a/docs/references/helix_models.rst b/docs/references/helix_models.rst
new file mode 100644
index 00000000..8971b951
--- /dev/null
+++ b/docs/references/helix_models.rst
@@ -0,0 +1,392 @@
+.. currentmodule:: twitchio
+
+
+Helix
+#################
+
+.. attributetable:: twitchio.CommercialStart
+
+.. autoclass:: twitchio.CommercialStart
+ :members:
+
+.. attributetable:: twitchio.AdSchedule
+
+.. autoclass:: twitchio.AdSchedule
+ :members:
+
+.. attributetable:: twitchio.SnoozeAd
+
+.. autoclass:: twitchio.SnoozeAd
+ :members:
+
+
+.. attributetable:: twitchio.ExtensionAnalytics
+
+.. autoclass:: twitchio.ExtensionAnalytics
+ :members:
+
+
+.. attributetable:: twitchio.GameAnalytics
+
+.. autoclass:: twitchio.GameAnalytics
+ :members:
+
+
+.. attributetable:: twitchio.BitsLeaderboard
+
+.. autoclass:: twitchio.BitsLeaderboard
+ :members:
+
+
+.. attributetable:: twitchio.BitLeaderboardUser
+
+.. autoclass:: twitchio.BitLeaderboardUser
+ :members:
+
+
+.. attributetable:: twitchio.Cheermote
+
+.. autoclass:: twitchio.Cheermote
+ :members:
+
+.. attributetable:: twitchio.CheermoteTier
+
+.. autoclass:: twitchio.CheermoteTier
+ :members:
+
+
+.. attributetable:: twitchio.ExtensionTransaction
+
+.. autoclass:: twitchio.ExtensionTransaction
+ :members:
+
+
+.. attributetable:: twitchio.ExtensionProductData
+
+.. autoclass:: twitchio.ExtensionProductData
+ :members:
+
+
+.. attributetable:: twitchio.ExtensionCost
+
+.. autoclass:: twitchio.ExtensionCost
+ :members:
+
+
+.. attributetable:: twitchio.ContentClassificationLabel
+
+.. autoclass:: twitchio.ContentClassificationLabel
+ :members:
+
+.. attributetable:: twitchio.RewardCooldown
+
+.. autoclass:: twitchio.RewardCooldown
+
+.. attributetable:: twitchio.RewardLimitSettings
+
+.. autoclass:: twitchio.RewardLimitSettings
+
+.. attributetable:: twitchio.CustomReward
+
+.. autoclass:: twitchio.CustomReward
+ :members:
+
+.. attributetable:: twitchio.CustomRewardRedemption
+
+.. autoclass:: twitchio.CustomRewardRedemption
+ :members:
+
+.. attributetable:: twitchio.ChannelEditor
+
+.. autoclass:: twitchio.ChannelEditor
+ :members:
+
+.. attributetable:: twitchio.FollowedChannelsEvent
+
+.. autoclass:: twitchio.FollowedChannelsEvent
+ :members:
+
+.. attributetable:: twitchio.FollowedChannels
+
+.. autoclass:: twitchio.FollowedChannels
+ :members:
+
+.. attributetable:: twitchio.ChannelFollowerEvent
+
+.. autoclass:: twitchio.ChannelFollowerEvent
+ :members:
+
+.. attributetable:: twitchio.ChannelFollowers
+
+.. autoclass:: twitchio.ChannelFollowers
+ :members:
+
+
+.. attributetable:: twitchio.ChannelInfo
+
+.. autoclass:: twitchio.ChannelInfo
+ :members:
+
+.. attributetable:: twitchio.CharityCampaign
+
+.. autoclass:: twitchio.CharityCampaign
+ :members:
+
+.. attributetable:: twitchio.CharityValues
+
+.. autoclass:: twitchio.CharityValues
+ :members:
+
+.. attributetable:: twitchio.CharityDonation
+
+.. autoclass:: twitchio.CharityDonation
+ :members:
+
+.. attributetable:: twitchio.Chatters
+
+.. autoclass:: twitchio.Chatters
+ :members:
+
+.. attributetable:: twitchio.ChatterColor
+
+.. autoclass:: twitchio.ChatterColor
+ :members:
+
+.. attributetable:: twitchio.ChatBadge
+
+.. autoclass:: twitchio.ChatBadge
+ :members:
+
+.. attributetable:: twitchio.ChatBadgeVersions
+
+.. autoclass:: twitchio.ChatBadgeVersions
+ :members:
+
+.. attributetable:: twitchio.Emote
+
+.. autoclass:: twitchio.Emote
+ :members:
+
+.. attributetable:: twitchio.GlobalEmote
+
+.. autoclass:: twitchio.GlobalEmote
+ :members:
+
+.. attributetable:: twitchio.EmoteSet
+
+.. autoclass:: twitchio.EmoteSet
+ :members:
+
+.. attributetable:: twitchio.ChannelEmote
+
+.. autoclass:: twitchio.ChannelEmote
+ :members:
+
+.. attributetable:: twitchio.UserEmote
+
+.. autoclass:: twitchio.UserEmote
+ :members:
+
+.. attributetable:: twitchio.ChatSettings
+
+.. autoclass:: twitchio.ChatSettings
+ :members:
+
+.. attributetable:: twitchio.SentMessage
+
+.. autoclass:: twitchio.SentMessage
+ :members:
+
+.. attributetable:: twitchio.SharedChatSession
+
+.. autoclass:: twitchio.SharedChatSession
+ :members:
+
+
+.. attributetable:: twitchio.Clip
+
+.. autoclass:: twitchio.Clip
+ :members:
+
+
+.. attributetable:: twitchio.Entitlement
+
+.. autoclass:: twitchio.Entitlement
+ :members:
+
+
+.. attributetable:: twitchio.EntitlementStatus
+
+.. autoclass:: twitchio.EntitlementStatus
+ :members:
+
+.. attributetable:: twitchio.Game
+
+.. autoclass:: twitchio.Game
+ :members:
+
+.. attributetable:: twitchio.Goal
+
+.. autoclass:: twitchio.Goal
+ :members:
+
+.. attributetable:: twitchio.HypeTrainEvent
+
+.. autoclass:: twitchio.HypeTrainEvent
+ :members:
+
+.. attributetable:: twitchio.HypeTrainContribution
+
+.. autoclass:: twitchio.HypeTrainContribution
+ :members:
+
+.. attributetable:: twitchio.AutoModStatus
+
+.. autoclass:: twitchio.AutoModStatus
+ :members:
+
+.. attributetable:: twitchio.AutomodCheckMessage
+
+.. autoclass:: twitchio.AutomodCheckMessage
+ :members:
+
+.. attributetable:: twitchio.AutomodSettings
+
+.. autoclass:: twitchio.AutomodSettings
+ :members:
+
+.. attributetable:: twitchio.BannedUser
+
+.. autoclass:: twitchio.BannedUser
+ :members:
+
+.. attributetable:: twitchio.Ban
+
+.. autoclass:: twitchio.Ban
+ :members:
+
+.. attributetable:: twitchio.Timeout
+
+.. autoclass:: twitchio.Timeout
+ :members:
+
+.. attributetable:: twitchio.UnbanRequest
+
+.. autoclass:: twitchio.UnbanRequest
+ :members:
+
+.. attributetable:: twitchio.BlockedTerm
+
+.. autoclass:: twitchio.BlockedTerm
+ :members:
+
+.. attributetable:: twitchio.ShieldModeStatus
+
+.. autoclass:: twitchio.ShieldModeStatus
+ :members:
+
+.. attributetable:: twitchio.Warning
+
+.. autoclass:: twitchio.Warning
+ :members:
+
+.. attributetable:: twitchio.Poll
+
+.. autoclass:: twitchio.Poll
+ :members:
+
+.. attributetable:: twitchio.PollChoice
+
+.. autoclass:: twitchio.PollChoice
+ :members:
+
+.. attributetable:: twitchio.Prediction
+
+.. autoclass:: twitchio.Prediction
+ :members:
+
+.. attributetable:: twitchio.PredictionOutcome
+
+.. autoclass:: twitchio.PredictionOutcome
+ :members:
+
+.. attributetable:: twitchio.Predictor
+
+.. autoclass:: twitchio.Predictor
+ :members:
+
+
+.. attributetable:: twitchio.Raid
+
+.. autoclass:: twitchio.Raid
+ :members:
+
+.. attributetable:: twitchio.Schedule
+
+.. autoclass:: twitchio.Schedule
+ :members:
+
+.. attributetable:: twitchio.ScheduleSegment
+
+.. autoclass:: twitchio.ScheduleSegment
+ :members:
+
+.. attributetable:: twitchio.ScheduleCategory
+
+.. autoclass:: twitchio.ScheduleCategory
+ :members:
+
+.. attributetable:: twitchio.ScheduleVacation
+
+.. autoclass:: twitchio.ScheduleVacation
+ :members:
+
+.. attributetable:: twitchio.SearchChannel
+
+.. autoclass:: twitchio.SearchChannel
+ :members:
+
+.. attributetable:: twitchio.Stream
+
+.. autoclass:: twitchio.Stream
+ :members:
+
+.. attributetable:: twitchio.StreamMarker
+
+.. autoclass:: twitchio.StreamMarker
+ :members:
+
+.. attributetable:: twitchio.VideoMarkers
+
+.. autoclass:: twitchio.VideoMarkers
+ :members:
+
+.. attributetable:: twitchio.UserSubscription
+
+.. autoclass:: twitchio.UserSubscription
+ :members:
+
+.. attributetable:: twitchio.BroadcasterSubscription
+
+.. autoclass:: twitchio.BroadcasterSubscription
+ :members:
+
+.. attributetable:: twitchio.BroadcasterSubscriptions
+
+.. autoclass:: twitchio.BroadcasterSubscriptions
+ :members:
+
+.. attributetable:: twitchio.Team
+
+.. autoclass:: twitchio.Team
+ :members:
+
+.. attributetable:: twitchio.ChannelTeam
+
+.. autoclass:: twitchio.ChannelTeam
+ :members:
+
+.. attributetable:: twitchio.Video
+
+.. autoclass:: twitchio.Video
+ :members:
diff --git a/docs/references/user.rst b/docs/references/user.rst
new file mode 100644
index 00000000..28789e8c
--- /dev/null
+++ b/docs/references/user.rst
@@ -0,0 +1,20 @@
+.. currentmodule:: twitchio
+
+
+User Reference
+#################
+
+.. attributetable:: twitchio.PartialUser
+
+.. autoclass:: twitchio.PartialUser
+ :members:
+
+.. attributetable:: twitchio.User
+
+.. autoclass:: twitchio.User
+ :members:
+
+.. attributetable:: twitchio.Chatter
+
+.. autoclass:: twitchio.Chatter
+ :members:
diff --git a/docs/references/utils.rst b/docs/references/utils.rst
new file mode 100644
index 00000000..071f74ae
--- /dev/null
+++ b/docs/references/utils.rst
@@ -0,0 +1,50 @@
+.. currentmodule:: twitchio
+
+
+Asset
+-----
+
+.. attributetable:: twitchio.Asset
+
+.. autoclass:: twitchio.Asset()
+ :members:
+
+
+Colour
+------
+.. attributetable:: twitchio.Colour
+
+.. autoclass:: twitchio.Colour()
+ :members:
+
+
+.. autoclass:: twitchio.Color()
+
+
+Scopes
+------
+
+.. attributetable:: twitchio.Scopes
+
+.. autoclass:: twitchio.Scopes
+ :members:
+
+
+Helpers
+-------
+
+.. autofunction:: twitchio.utils.url_encode_datetime
+
+.. autofunction:: twitchio.utils.parse_timestamp
+
+.. autofunction:: twitchio.utils.setup_logging
+
+
+HTTP
+----
+
+.. attributetable:: twitchio.Route
+
+.. autoclass:: twitchio.Route()
+
+.. autoclass:: twitchio.HTTPAsyncIterator()
diff --git a/docs/references/web.rst b/docs/references/web.rst
new file mode 100644
index 00000000..984b3e17
--- /dev/null
+++ b/docs/references/web.rst
@@ -0,0 +1,15 @@
+.. currentmodule:: twitchio
+
+
+Web/OAuth Reference
+###################
+
+.. attributetable:: twitchio.web.AiohttpAdapter
+
+.. autoclass:: twitchio.web.AiohttpAdapter
+ :members:
+
+.. attributetable:: twitchio.web.StarletteAdapter
+
+.. autoclass:: twitchio.web.StarletteAdapter
+ :members:
\ No newline at end of file
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 40eb98e7..8c92bdb9 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,11 +1,9 @@
+sphinx-wagtail-theme
sphinx
docutils<0.18
sphinxcontrib-napoleon
sphinxcontrib-asyncio
sphinxcontrib-websupport
-sphinxext-opengraph
Pygments
-furo
-pyaudio==0.2.11
-yt-dlp>=2022.2.4
-tinytag>=1.9.0
\ No newline at end of file
+sphinx-hoverxref
+sphinxcontrib_trio
diff --git a/docs/sidebar_logo.png b/docs/sidebar_logo.png
deleted file mode 100644
index 7b8416b4..00000000
Binary files a/docs/sidebar_logo.png and /dev/null differ
diff --git a/docs/twitchio.rst b/docs/twitchio.rst
deleted file mode 100644
index 34f9d823..00000000
--- a/docs/twitchio.rst
+++ /dev/null
@@ -1,50 +0,0 @@
-.. currentmodule:: twitchio
-
-
-Client Reference
-================
-Event, Client and Exceptions API reference.
-
-.. note::
-
- For the **command extension** see: :any:`commands-ref`
-
-Client
---------
-
-.. attributetable:: Client
-
-.. autoclass:: Client
- :members:
- :exclude-members: event_ready, event_raw_data, event_message,
- event_join, event_part, event_mode, event_userstate,
- event_raw_usernotice, event_usernotice_subscription, event_error,
- event_channel_join_failure, event_reconnect
-
-Event Reference
------------------
-
-.. automethod:: Client.event_ready()
-.. automethod:: Client.event_reconnect()
-.. automethod:: Client.event_raw_data(data: str)
-.. automethod:: Client.event_message(message: Message)
-.. automethod:: Client.event_join(channel: Channel, user: User)
-.. automethod:: Client.event_part(user: User)
-.. automethod:: Client.event_mode(channel: Channel, user: User, status: str)
-.. automethod:: Client.event_userstate(user: User)
-.. automethod:: Client.event_raw_usernotice(channel: Channel, tags: dict)
-.. automethod:: Client.event_usernotice_subscription(metadata)
-.. automethod:: Client.event_error(error: Exception, data: Optional[str] = None)
-.. automethod:: Client.event_channel_join_failure(channel: str)
-
-Exceptions
-------------
-.. autoexception:: TwitchIOException
-.. autoexception:: AuthenticationError
-.. autoexception:: InvalidContent
-.. autoexception:: IRCCooldownError
-.. autoexception:: EchoMessageWarning
-.. autoexception:: NoClientID
-.. autoexception:: NoToken
-.. autoexception:: HTTPException
-.. autoexception:: Unauthorized
\ No newline at end of file
diff --git a/examples/alpha_example/main.py b/examples/alpha_example/main.py
new file mode 100644
index 00000000..1338abb7
--- /dev/null
+++ b/examples/alpha_example/main.py
@@ -0,0 +1,167 @@
+import asyncio
+import logging
+import sqlite3
+
+import asqlite
+import twitchio
+from twitchio.ext import commands
+from twitchio import eventsub
+
+
+LOGGER: logging.Logger = logging.getLogger("Bot")
+
+# Simple example for TwitchIO V3 Alpha...
+# Instructions:
+
+# You need to install: https://github.com/Rapptz/asqlite
+# pip install -U git+https://github.com/Rapptz/asqlite.git
+
+# 1.) Comment out lines: 54-60 (The subscriptions)
+# 2.) Add the Twitch Developer Console and Create an Application
+# 3.) Add: http://localhost:4343/oauth/callback as the callback URL
+# 4.) Enter your CLIENT_ID, CLIENT_SECRET, BOT_ID and OWNER_ID
+# 5.) Run the bot.
+# 6.) Logged in the bots user account, visit: http://localhost:4343/oauth?scopes=user:read:chat%20user:write:chat%20user:bot
+# 7.) Logged in as your personal user account, visit: http://localhost:4343/oauth?scopes=channel:bot
+# 8.) Uncomment lines: 54-60 (The subscriptions)
+# 9.) Restart the bot.
+# You only have to do the above once for this example.
+
+
+CLIENT_ID: str = "..."
+CLIENT_SECRET: str = "..."
+BOT_ID = "..." # The Account ID of the bot user...
+OWNER_ID = "..." # Your personal User ID..
+
+
+class Bot(commands.Bot):
+ def __init__(self, *, token_database: asqlite.Pool) -> None:
+ self.token_database = token_database
+ super().__init__(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ bot_id=BOT_ID,
+ owner_id=OWNER_ID,
+ prefix="!",
+ )
+
+ async def setup_hook(self) -> None:
+ # Add our component which contains our commands...
+ await self.add_component(MyComponent(self))
+
+ # Subscribe to read chat (event_message) from our channel as the bot...
+ # This creates and opens a websocket to Twitch EventSub...
+ subscription = eventsub.ChatMessageSubscription(broadcaster_user_id=OWNER_ID, user_id=BOT_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ # Subscribe and listen to when a stream goes live..
+ # For this example listen to our own stream...
+ subscription = eventsub.StreamOnlineSubscription(broadcaster_user_id=OWNER_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ async def add_token(self, token: str, refresh: str) -> twitchio.authentication.ValidateTokenPayload:
+ # Make sure to call super() as it will add the tokens interally and return us some data...
+ resp: twitchio.authentication.ValidateTokenPayload = await super().add_token(token, refresh)
+
+ # Store our tokens in a simple SQLite Database when they are authorized...
+ query = """
+ INSERT INTO tokens (user_id, token, refresh)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id)
+ DO UPDATE SET
+ token = excluded.token,
+ refresh = excluded.refresh;
+ """
+
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query, (resp.user_id, token, refresh))
+
+ LOGGER.info("Added token to the database for user: %s", resp.user_id)
+ return resp
+
+ async def load_tokens(self, path: str | None = None) -> None:
+ # We don't need to call this manually, it is called in .login() from .start() internally...
+
+ async with self.token_database.acquire() as connection:
+ rows: list[sqlite3.Row] = await connection.fetchall("""SELECT * from tokens""")
+
+ for row in rows:
+ await self.add_token(row["token"], row["refresh"])
+
+ async def setup_database(self) -> None:
+ # Create our token table, if it doesn't exist..
+ query = """CREATE TABLE IF NOT EXISTS tokens(user_id TEXT PRIMARY KEY, token TEXT NOT NULL, refresh TEXT NOT NULL)"""
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query)
+
+ async def event_ready(self) -> None:
+ LOGGER.info("Successfully logged in as: %s", self.bot_id)
+
+
+class MyComponent(commands.Component):
+ def __init__(self, bot: Bot):
+ # Passing args is not required...
+ # We pass bot here as an example...
+ self.bot = bot
+
+ @commands.command(aliases=["hello", "howdy", "hey"])
+ async def hi(self, ctx: commands.Context) -> None:
+ """Simple command that says hello!
+
+ !hi, !hello, !howdy, !hey
+ """
+ await ctx.reply(f"Hello {ctx.chatter.mention}!")
+
+ @commands.group(invoke_fallback=True)
+ async def socials(self, ctx: commands.Context) -> None:
+ """Group command for our social links.
+
+ !socials
+ """
+ await ctx.send("discord.gg/..., youtube.com/..., twitch.tv/...")
+
+ @socials.command(name="discord")
+ async def socials_discord(self, ctx: commands.Context) -> None:
+ """Sub command of socials that sends only our discord invite.
+
+ !socials discord
+ """
+ await ctx.send("discord.gg/...")
+
+ @commands.command(aliases=["repeat"])
+ @commands.is_moderator()
+ async def say(self, ctx: commands.Context, *, content: str) -> None:
+ """Moderator only command which repeats back what you say.
+
+ !say hello world, !repeat I am cool LUL
+ """
+ await ctx.send(content)
+
+ @commands.Component.listener()
+ async def event_stream_online(self, payload: twitchio.StreamOnline) -> None:
+ # Event dispatched when a user goes live from the subscription we made above...
+
+ # Keep in mind we are assuming this is for ourselves
+ # others may not want your bot randomly sending messages...
+ await payload.broadcaster.send_message(
+ sender=self.bot.bot_id,
+ message=f"Hi... {payload.broadcaster}! You are live!",
+ )
+
+
+def main() -> None:
+ twitchio.utils.setup_logging(level=logging.INFO)
+
+ async def runner() -> None:
+ async with asqlite.create_pool("tokens.db") as tdb, Bot(token_database=tdb) as bot:
+ await bot.setup_database()
+ await bot.start()
+
+ try:
+ asyncio.run(runner())
+ except KeyboardInterrupt:
+ LOGGER.warning("Shutting down due to KeyboardInterrupt...")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/basic_bot.py b/examples/basic_bot.py
deleted file mode 100644
index 250bee29..00000000
--- a/examples/basic_bot.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from twitchio.ext import commands
-
-
-class Bot(commands.Bot):
-
- def __init__(self):
- # Initialise our Bot with our access token, prefix and a list of channels to join on boot...
- # prefix can be a callable, which returns a list of strings or a string...
- # initial_channels can also be a callable which returns a list of strings...
- super().__init__(token='ACCESS_TOKEN', prefix='?', initial_channels=['...'])
-
- async def event_ready(self):
- # Notify us when everything is ready!
- # We are logged in and ready to chat and use commands...
- print(f'Logged in as | {self.nick}')
- print(f'User id is | {self.user_id}')
-
- async def event_message(self, message):
- # Messages with echo set to True are messages sent by the bot...
- # For now we just want to ignore them...
- if message.echo:
- return
-
- # Print the contents of our message to console...
- print(message.content)
-
- # Since we have commands and are overriding the default `event_message`
- # We must let the bot know we want to handle and invoke our commands...
- await self.handle_commands(message)
-
- @commands.command()
- async def hello(self, ctx: commands.Context):
- # Here we have a command hello, we can invoke our command with our prefix and command name
- # e.g ?hello
- # We can also give our commands aliases (different names) to invoke with.
-
- # Send a hello back!
- # Sending a reply back to the channel is easy... Below is an example.
- await ctx.send(f'Hello {ctx.author.name}!')
-
-
-bot = Bot()
-bot.run()
-# bot.run() is blocking and will stop execution of any below code here until stopped or closed.
\ No newline at end of file
diff --git a/examples/basic_routine.py b/examples/basic_routine.py
deleted file mode 100644
index 3458af5d..00000000
--- a/examples/basic_routine.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from twitchio.ext import routines
-
-# This routine will run every 5 seconds for 5 iterations.
-@routines.routine(seconds=5.0, iterations=5)
-async def hello(arg: str):
- print(f'Hello {arg}!')
-
-
-hello.start('World')
\ No newline at end of file
diff --git a/examples/eventsub.py b/examples/eventsub.py
deleted file mode 100644
index 6a686bb2..00000000
--- a/examples/eventsub.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import twitchio
-from twitchio.ext import commands, eventsub
-
-esbot = commands.Bot.from_client_credentials(client_id="...", client_secret="...")
-esclient = eventsub.EventSubClient(esbot, webhook_secret="...", callback_route="https://your-url.here/callback")
-
-
-class Bot(commands.Bot):
- def __init__(self):
- super().__init__(token="...", prefix="!", initial_channels=["channel"])
-
- async def __ainit__(self) -> None:
- self.loop.create_task(esclient.listen(port=4000))
- try:
- await esclient.subscribe_channel_follows_v2(broadcaster=channel_id, moderator=moderator_id)
- except twitchio.HTTPException:
- pass
-
- async def event_ready(self):
- print("Bot is ready!")
-
-
-bot = Bot()
-bot.loop.run_until_complete(bot.__ainit__())
-
-
-@esbot.event()
-async def event_eventsub_notification_followV2(payload: eventsub.ChannelFollowData) -> None:
- print("Received event!")
- channel = bot.get_channel("channel")
- await channel.send(f"{payload.data.user.name} followed woohoo!")
-
-
-bot.run()
diff --git a/examples/module_examples/standard/components/cmds.py b/examples/module_examples/standard/components/cmds.py
new file mode 100644
index 00000000..880e640f
--- /dev/null
+++ b/examples/module_examples/standard/components/cmds.py
@@ -0,0 +1,61 @@
+import twitchio
+from twitchio.ext import commands
+
+class MyComponent(commands.Component):
+ def __init__(self, bot: commands.Bot) -> None:
+ # Passing args is not required...
+ # We pass bot here as an example...
+ self.bot = bot
+
+ @commands.command(aliases=["hello", "howdy", "hey"])
+ async def hi(self, ctx: commands.Context) -> None:
+ """Simple command that says hello!
+
+ !hi, !hello, !howdy, !hey
+ """
+ await ctx.reply(f"Hello {ctx.chatter.mention}!")
+
+ @commands.group(invoke_fallback=True)
+ async def socials(self, ctx: commands.Context) -> None:
+ """Group command for our social links.
+
+ !socials
+ """
+ await ctx.send("discord.gg/..., youtube.com/..., twitch.tv/...")
+
+ @socials.command(name="discord")
+ async def socials_discord(self, ctx: commands.Context) -> None:
+ """Sub command of socials that sends only our discord invite.
+
+ !socials discord
+ """
+ await ctx.send("discord.gg/...")
+
+ @commands.command(aliases=["repeat"])
+ @commands.is_moderator()
+ async def say(self, ctx: commands.Context, *, content: str) -> None:
+ """Moderator only command which repeats back what you say.
+
+ !say hello world, !repeat I am cool LUL
+ """
+ await ctx.send(content)
+
+ @commands.Component.listener()
+ async def event_stream_online(self, payload: twitchio.StreamOnline) -> None:
+ # Event dispatched when a user goes live from the subscription we made above...
+
+ # Keep in mind we are assuming this is for ourselves
+ # others may not want your bot randomly sending messages...
+ await payload.broadcaster.send_message(
+ sender=self.bot.bot_id,
+ message=f"Hi... {payload.broadcaster}! You are live!!!",
+ )
+
+# This is our entry point for the module.
+async def setup(bot: commands.Bot) -> None:
+ await bot.add_component(MyComponent(bot))
+
+
+# This is an optional teardown coroutine for miscellaneous clean-up if necessary.
+async def teardown(bot: commands.Bot) -> None:
+ ...
diff --git a/examples/module_examples/standard/components/owner_cmds.py b/examples/module_examples/standard/components/owner_cmds.py
new file mode 100644
index 00000000..87442837
--- /dev/null
+++ b/examples/module_examples/standard/components/owner_cmds.py
@@ -0,0 +1,57 @@
+from twitchio.ext import commands
+
+# Custom Exception for our component guard.
+class NotOwnerError(commands.GuardFailure): ...
+
+class OwnerCmds(commands.Component):
+ def __init__(self, bot: commands.Bot) -> None:
+ # Passing args is not required...
+ # We pass bot here as an example...
+ self.bot = bot
+
+
+ async def component_command_error(self, payload: commands.CommandErrorPayload) -> bool | None:
+ error = payload.exception
+ if isinstance(error, NotOwnerError):
+ ctx = payload.context
+
+ await ctx.reply("Only the owner can use this command!")
+
+ # This explicit False return stops the error from being dispatched anywhere else...
+ return False
+
+ # Restrict all of the commands in this component to the owner.
+ @commands.Component.guard()
+ def is_owner(self, ctx: commands.Context) -> bool:
+ if ctx.chatter.id != self.bot.owner_id:
+ raise NotOwnerError
+
+ return True
+
+ # Manually load the cmds module.
+ @commands.command()
+ async def load_cmds(self, ctx: commands.Context) -> None:
+ await self.bot.load_module("components.cmds")
+
+ # Manually unload the cmds module.
+ @commands.command()
+ async def unload_cmds(self, ctx: commands.Context) -> None:
+ await self.bot.unload_module("components.cmds")
+
+ # Hot reload the cmds module atomically.
+ @commands.command()
+ async def reload_cmds(self, ctx: commands.Context) -> None:
+ await self.bot.reload_module("components.cmds")
+
+ # Check which modules are loaded.
+ @commands.command()
+ async def loaded_modules(self, ctx: commands.Context) -> None:
+ print(self.bot.modules)
+
+# This is our entry point for the module.
+async def setup(bot: commands.Bot) -> None:
+ await bot.add_component(OwnerCmds(bot))
+
+# This is an optional teardown coroutine for miscellaneous clean-up if necessary.
+async def teardown(bot: commands.Bot) -> None:
+ ...
diff --git a/examples/module_examples/standard/main.py b/examples/module_examples/standard/main.py
new file mode 100644
index 00000000..c5e64219
--- /dev/null
+++ b/examples/module_examples/standard/main.py
@@ -0,0 +1,124 @@
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+import asqlite
+
+import twitchio
+from twitchio import eventsub
+from twitchio.ext import commands
+
+
+if TYPE_CHECKING:
+ import sqlite3
+
+
+LOGGER: logging.Logger = logging.getLogger("Bot")
+
+# Simple example for TwitchIO V3 Alpha...
+# Instructions:
+
+# You need to install: https://github.com/Rapptz/asqlite
+# pip install -U git+https://github.com/Rapptz/asqlite.git
+
+# 1.) Comment out lines: 54-60 (The subscriptions)
+# 2.) Add the Twitch Developer Console and Create an Application
+# 3.) Add: http://localhost:4343/oauth/callback as the callback URL
+# 4.) Enter your CLIENT_ID, CLIENT_SECRET, BOT_ID and OWNER_ID
+# 5.) Run the bot.
+# 6.) Logged in the bots user account, visit: http://localhost:4343/oauth?scopes=user:read:chat%20user:write:chat%20user:bot
+# 7.) Logged in as your personal user account, visit: http://localhost:4343/oauth?scopes=channel:bot
+# 8.) Uncomment lines: 54-60 (The subscriptions)
+# 9.) Restart the bot.
+# You only have to do the above once for this example.
+
+
+CLIENT_ID: str = "..."
+CLIENT_SECRET: str = "..."
+BOT_ID = "..."
+OWNER_ID = "..."
+
+
+class Bot(commands.Bot):
+ def __init__(self, *, token_database: asqlite.Pool) -> None:
+ self.token_database = token_database
+ super().__init__(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ bot_id=BOT_ID,
+ owner_id=OWNER_ID,
+ prefix="!",
+ )
+
+ async def setup_hook(self) -> None:
+ # Subscribe to read chat (event_message) from our channel as the bot...
+ # This creates and opens a websocket to Twitch EventSub...
+ subscription = eventsub.ChatMessageSubscription(broadcaster_user_id=OWNER_ID, user_id=BOT_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ # Subscribe and listen to when a stream goes live..
+ # For this example listen to our own stream...
+ subscription = eventsub.StreamOnlineSubscription(broadcaster_user_id=OWNER_ID)
+ await self.subscribe_websocket(payload=subscription)
+
+ # Load the module that contains our component, commands, and listeners.
+ # Modules can have multiple components.
+ await self.load_module("components.owner_cmds")
+ await self.load_module("components.cmds")
+
+ async def add_token(self, token: str, refresh: str) -> twitchio.authentication.ValidateTokenPayload:
+ # Make sure to call super() as it will add the tokens interally and return us some data...
+ resp: twitchio.authentication.ValidateTokenPayload = await super().add_token(token, refresh)
+
+ # Store our tokens in a simple SQLite Database when they are authorized...
+ query = """
+ INSERT INTO tokens (user_id, token, refresh)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id)
+ DO UPDATE SET
+ token = excluded.token,
+ refresh = excluded.refresh;
+ """
+
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query, (resp.user_id, token, refresh))
+
+ LOGGER.info("Added token to the database for user: %s", resp.user_id)
+ return resp
+
+ async def load_tokens(self, path: str | None = None) -> None:
+ # We don't need to call this manually, it is called in .login() from .start() internally...
+
+ async with self.token_database.acquire() as connection:
+ rows: list[sqlite3.Row] = await connection.fetchall("""SELECT * from tokens""")
+
+ for row in rows:
+ await self.add_token(row["token"], row["refresh"])
+
+ async def setup_database(self) -> None:
+ # Create our token table, if it doesn't exist..
+ query = """CREATE TABLE IF NOT EXISTS tokens(user_id TEXT PRIMARY KEY, token TEXT NOT NULL, refresh TEXT NOT NULL)"""
+ async with self.token_database.acquire() as connection:
+ await connection.execute(query)
+
+ async def event_ready(self) -> None:
+ LOGGER.info("Successfully logged in as: %s", self.bot_id)
+
+
+
+def main() -> None:
+ twitchio.utils.setup_logging(level=logging.INFO)
+
+ async def runner() -> None:
+ async with asqlite.create_pool("tokens.db") as tdb, Bot(token_database=tdb) as bot:
+ await bot.setup_database()
+ await bot.start()
+
+ try:
+ asyncio.run(runner())
+ except KeyboardInterrupt:
+ LOGGER.warning("Shutting down due to KeyboardInterrupt...")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/logo.png b/logo.png
index 9b1831d6..617c35a4 100644
Binary files a/logo.png and b/logo.png differ
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..3fcb2e5b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,127 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "twitchio"
+authors = [{ name = "PythonistaGuild" }]
+dynamic = ["dependencies", "version"]
+description = "A powerful, asynchronous Python library for twitch.tv."
+readme = "README.md"
+requires-python = ">=3.11"
+classifiers = [
+ "License :: OSI Approved :: MIT License",
+ "Intended Audience :: Developers",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Internet",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Utilities",
+]
+
+[project.urls]
+Homepage = "https://github.com/PythonistaGuild/TwitchIO"
+Documentation = "https://twitchio.dev"
+"Issue tracker" = "https://github.com/PythonistaGuild/TwitchIO/issues"
+
+[tool.setuptools]
+packages = [
+ "twitchio",
+ "twitchio.models",
+ "twitchio.eventsub",
+ "twitchio.authentication",
+ "twitchio.web",
+ "twitchio.ext.commands",
+ "twitchio.ext.routines",
+ "twitchio.types_",
+]
+include-package-data = true
+
+[tool.setuptools.package-data]
+twitchio = ["py.typed"]
+
+[tool.setuptools.dynamic]
+dependencies = { file = ["requirements.txt"] }
+
+[project.optional-dependencies]
+docs = [
+ "sphinx-wagtail-theme",
+ "sphinx",
+ "docutils<0.18",
+ "sphinxcontrib-napoleon",
+ "sphinxcontrib-asyncio",
+ "sphinxcontrib-websupport",
+ "Pygments",
+ "sphinx-hoverxref",
+ "sphinxcontrib_trio",
+]
+starlette = ["starlette", "uvicorn"]
+dev = ["ruff", "pyright", "isort"]
+
+[tool.ruff]
+line-length = 125
+target-version = "py311"
+indent-width = 4
+exclude = ["venv", "docs", "examples"]
+
+[tool.ruff.lint]
+select = [
+ "C4",
+ "E",
+ "F",
+ "G",
+ "I",
+ "PTH",
+ "RUF",
+ "SIM",
+ "TC",
+ "UP",
+ "W",
+ "PERF",
+ "ANN",
+]
+ignore = [
+ "F402",
+ "F403",
+ "F405",
+ "PERF203",
+ "RUF001",
+ "RUF009",
+ "SIM105",
+ "UP034",
+ "UP038",
+ "ANN401",
+ "UP031",
+ "PTH123",
+ "E203",
+ "E501",
+]
+
+[tool.ruff.lint.isort]
+split-on-trailing-comma = true
+combine-as-imports = true
+lines-after-imports = 2
+
+[tool.ruff.lint.flake8-annotations]
+allow-star-arg-any = true
+
+[tool.ruff.lint.flake8-quotes]
+inline-quotes = "double"
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+
+[tool.pyright]
+exclude = ["venv", "docs", "examples", "twitchio/__main__.py"]
+useLibraryCodeForTypes = true
+typeCheckingMode = "strict"
+reportImportCycles = false
+reportPrivateUsage = false
+pythonVersion = "3.11"
diff --git a/requirements.txt b/requirements.txt
index 41194b6b..a2595245 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1 @@
-aiohttp>=3.6.0,<4
-iso8601
-typing-extensions
\ No newline at end of file
+aiohttp>=3.9.1,<4
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 3c0f4d5d..e9199933 100644
--- a/setup.py
+++ b/setup.py
@@ -1,97 +1,31 @@
-# -*- coding: utf-8 -*-
-
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import os
-import pathlib
import re
-from setuptools import setup
+
+from setuptools import setup # type: ignore
-ROOT = pathlib.Path(__file__).parent
-on_rtd = os.getenv("READTHEDOCS") == "True"
+def get_version() -> str:
+ version = ""
+ with open("twitchio/__init__.py") as f:
+ match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE)
-with open("requirements.txt") as f:
- requirements = f.read().splitlines()
+ if not match or not match.group(1):
+ raise RuntimeError("Version is not set")
-if on_rtd:
- with open("docs/requirements.txt") as f:
- requirements.extend(f.read().splitlines())
+ version = match.group(1)
-with open(ROOT / "twitchio" / "__init__.py", encoding="utf-8") as f:
- VERSION = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1)
+ if version.endswith(("dev", "a", "b", "rc")):
+ # append version identifier based on commit count
+ try:
+ import subprocess
-readme = ""
-with open("README.rst") as f:
- readme = f.read()
+ p = subprocess.Popen(["git", "rev-list", "--count", "HEAD"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ out, _ = p.communicate()
+ if out:
+ version += out.decode("utf-8").strip()
+ except Exception:
+ pass
+ return version
-sounds = [
- "yt-dlp>=2022.2.4",
- 'pyaudio==0.2.11; platform_system!="Windows"',
- 'tinytag>=1.9.0',
-]
-speed = [
- "ujson>=5.2,<6",
- "ciso8601>=2.2,<3",
- "cchardet>=2.1,<3"
-]
-extras_require = {"sounds": sounds, "speed": speed}
-setup(
- name="twitchio",
- author="TwitchIO",
- url="https://github.com/TwitchIO/TwitchIO",
- version=VERSION,
- packages=[
- "twitchio",
- "twitchio.ext.commands",
- "twitchio.ext.pubsub",
- "twitchio.ext.routines",
- "twitchio.ext.eventsub",
- "twitchio.ext.sounds",
- ],
- license="MIT",
- description="An asynchronous Python IRC and API wrapper for Twitch.",
- long_description=readme,
- include_package_data=True,
- install_requires=requirements,
- extras_require=extras_require,
- classifiers=[
- "License :: OSI Approved :: MIT License",
- "Intended Audience :: Developers",
- "Natural Language :: English",
- "Operating System :: OS Independent",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Topic :: Internet",
- "Topic :: Software Development :: Libraries",
- "Topic :: Software Development :: Libraries :: Python Modules",
- "Topic :: Utilities",
- ],
-)
+setup(version=get_version())
diff --git a/twitchio/__init__.py b/twitchio/__init__.py
index 2e9805fa..b9b61525 100644
--- a/twitchio/__init__.py
+++ b/twitchio/__init__.py
@@ -1,42 +1,46 @@
-# -*- coding: utf-8 -*-
-
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
__title__ = "TwitchIO"
-__author__ = "TwitchIO, PythonistaGuild"
+__author__ = "PythonistaGuild"
__license__ = "MIT"
-__copyright__ = "Copyright 2017-present (c) TwitchIO"
-__version__ = "2.10.1"
+__copyright__ = "Copyright 2017-Present (c) TwitchIO, PythonistaGuild"
+__version__ = "3.0.0b"
-from .client import Client
-from .user import *
-from .channel import Channel
-from .chatter import Chatter, PartialChatter
-from .enums import *
-from .errors import *
-from .message import Message, HypeChatData
+from . import ( # noqa: F401
+ authentication as authentication,
+ eventsub as eventsub,
+ types_ as types, # pyright: ignore [reportUnusedImport]
+ utils as utils,
+ web as web,
+)
+from .assets import Asset as Asset
+from .authentication import Scopes as Scopes
+from .client import Client as Client
+from .exceptions import *
+from .http import HTTPAsyncIterator as HTTPAsyncIterator, Route as Route
from .models import *
-from .rewards import *
-from .utils import *
+from .payloads import *
+from .user import *
+from .utils import Color as Color, Colour as Colour
diff --git a/twitchio/__main__.py b/twitchio/__main__.py
new file mode 100644
index 00000000..7af5e3f5
--- /dev/null
+++ b/twitchio/__main__.py
@@ -0,0 +1,103 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+import argparse
+import platform
+import re
+import sys
+
+import aiohttp
+
+
+try:
+ import starlette
+
+ starlette_version = starlette.__version__
+except ImportError:
+ starlette_version = "Not Installed/Not Found"
+
+try:
+ import uvicorn
+
+ uvicorn_version = uvicorn.__version__
+except ImportError:
+ uvicorn_version = "Not Installed/Not Found"
+
+
+parser = argparse.ArgumentParser(prog="twitchio")
+parser.add_argument("--version", action="store_true", help="Get version and debug information for TwitchIO.")
+
+args = parser.parse_args()
+
+
+def get_version() -> str:
+ version = ""
+ with open("twitchio/__init__.py") as f:
+ match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE)
+
+ if not match or not match.group(1):
+ raise RuntimeError("Version is not set")
+
+ version = match.group(1)
+
+ if version.endswith(("dev", "a", "b", "rc")):
+ # append version identifier based on commit count
+ try:
+ import subprocess
+
+ p = subprocess.Popen(["git", "rev-list", "--count", "HEAD"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ out, _ = p.communicate()
+ if out:
+ version += out.decode("utf-8").strip()
+ p = subprocess.Popen(["git", "rev-parse", "--short", "HEAD"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ out, _ = p.communicate()
+ if out:
+ version += "+g" + out.decode("utf-8").strip()
+ except Exception:
+ pass
+
+ return version
+
+
+def version_info() -> None:
+ python_info = "\n".join(sys.version.split("\n"))
+
+ info: str = f"""
+ twitchio : {get_version()}
+ aiohttp : {aiohttp.__version__}
+
+ Python:
+ - {python_info}
+ System:
+ - {platform.platform()}
+ Extras:
+ - Starlette : {starlette_version}
+ - Uvicorn : {uvicorn_version}
+ """
+
+ print(info)
+
+
+if args.version:
+ version_info()
diff --git a/twitchio/abcs.py b/twitchio/abcs.py
deleted file mode 100644
index 054ce723..00000000
--- a/twitchio/abcs.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import abc
-import time
-
-from .cooldowns import RateBucket
-from .errors import *
-
-
-class IRCLimiterMapping:
- def __init__(self):
- self.buckets = {}
-
- def get_bucket(self, channel: str, method: str) -> RateBucket:
- try:
- bucket = self.buckets[channel]
- except KeyError:
- bucket = RateBucket(method=method)
- self.buckets[channel] = bucket
- if bucket.method != method:
- bucket.method = method
- bucket.limit = bucket.MODLIMIT if method == "mod" else bucket.IRCLIMIT
- self.buckets[channel] = bucket
- return bucket
-
-
-limiter = IRCLimiterMapping()
-
-
-class Messageable(abc.ABC):
- __slots__ = ()
-
- @abc.abstractmethod
- def _fetch_channel(self):
- raise NotImplementedError
-
- @abc.abstractmethod
- def _fetch_websocket(self):
- raise NotImplementedError
-
- @abc.abstractmethod
- def _fetch_message(self):
- raise NotImplementedError
-
- @abc.abstractmethod
- def _bot_is_mod(self):
- raise NotImplementedError
-
- def check_bucket(self, channel):
- mod = self._bot_is_mod()
-
- if mod:
- bucket = limiter.get_bucket(channel=channel, method="mod")
- else:
- bucket = limiter.get_bucket(channel=channel, method="irc")
- now = time.time()
- bucket.update()
-
- if bucket.limited:
- raise IRCCooldownError(
- f"IRC Message rate limit reached for channel <{channel}>."
- f" Please try again in {bucket._reset - now:.2f}s"
- )
-
- def check_content(self, content: str):
- if len(content) > 500:
- raise InvalidContent("Content must not exceed 500 characters.")
-
- async def send(self, content: str):
- """|coro|
-
-
- Send a message to the destination associated with the dataclass.
-
- Destination will either be a channel or user.
-
- Parameters
- ------------
- content: str
- The content you wish to send as a message. The content must be a string.
-
- Raises
- --------
- InvalidContent
- Invalid content.
- """
- entity = self._fetch_channel()
- ws = self._fetch_websocket()
-
- self.check_content(content)
- self.check_bucket(channel=entity.name)
-
- try:
- name = entity.channel.name
- except AttributeError:
- name = entity.name
- if entity.__messageable_channel__:
- await ws.send(f"PRIVMSG #{name} :{content}\r\n")
- else:
- await ws.send(f"PRIVMSG #jtv :/w {entity.name} {content}\r\n")
diff --git a/twitchio/assets.py b/twitchio/assets.py
new file mode 100644
index 00000000..8c460f3e
--- /dev/null
+++ b/twitchio/assets.py
@@ -0,0 +1,389 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import pathlib
+from typing import TYPE_CHECKING, Any
+
+import yarl
+
+from .exceptions import HTTPException
+
+
+if TYPE_CHECKING:
+ import os
+
+ from .http import HTTPClient
+
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+
+VALID_ASSET_EXTENSIONS: set[str] = {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+}
+
+
+class Asset:
+ """Represents an asset from Twitch.
+
+ Assets can be used to save or read from images or other media from Twitch.
+ You can also retrieve the URL of the asset via the provided properties and methods.
+
+ .. versionadded:: 3.0.0
+ Added the asset class which will replace all
+ previous properties of models with attached media URLs.
+
+ Supported Operations
+ --------------------
+
+ +-------------+-------------------------------------------+-----------------------------------------------+
+ | Operation | Usage(s) | Description |
+ +=============+===========================================+===============================================+
+ | __str__ | ``str(asset)``, ``f"{asset}"`` | Returns the asset's URL. |
+ +-------------+-------------------------------------------+-----------------------------------------------+
+ | __repr__ | ``repr(asset)``, ``f"{asset!r}"`` | Returns the asset's official representation. |
+ +-------------+-------------------------------------------+-----------------------------------------------+
+ """
+
+ __slots__ = ("_dimensions", "_ext", "_http", "_name", "_original_url", "_url")
+
+ def __init__(
+ self,
+ url: str,
+ *,
+ http: HTTPClient,
+ name: str | None = None,
+ dimensions: tuple[int, int] | None = None,
+ ) -> None:
+ self._http: HTTPClient = http
+
+ ext: str = yarl.URL(url).suffix
+ self._ext: str | None = ext if ext in VALID_ASSET_EXTENSIONS else None
+
+ self._dimensions: tuple[int, int] | None = dimensions
+ self._original_url: str = url
+ self._url: str = url.format(width=dimensions[0], height=dimensions[1]) if dimensions else url
+ self._name: str = name or yarl.URL(self._url).name
+
+ def __str__(self) -> str:
+ return self.url
+
+ def __repr__(self) -> str:
+ return f"Asset(name={self.name}, url={self.url})"
+
+ @property
+ def url(self) -> str:
+ """The URL of the asset.
+
+ If the asset supports custom dimensions, the URL will contain the dimensions set.
+
+ See :meth:`.set_dimensions` for information on setting custom dimensions.
+ """
+ return self._url
+
+ @property
+ def base_url(self) -> str:
+ """The base URL of the asset without any dimensions set.
+
+ This is the URL provided by Twitch before any dimensions are set.
+ """
+ return self._original_url
+
+ @property
+ def name(self) -> str:
+ """A property that returns the default name of the asset."""
+ return self._name
+
+ @property
+ def qualified_name(self) -> str:
+ """A property that returns the qualified name of the asset.
+
+ This is the name of the asset with the file extension if one can be determined.
+ If the file extension has not been set, this method returns the same as :attr:`.name`.
+ """
+ name: str = self._name.split(".")[0]
+ return f"{name}{self._ext}" if self._ext else self._name
+
+ @property
+ def ext(self) -> str | None:
+ """A property that returns the file extension of the asset.
+
+ Could be ``None`` if the asset does not have a valid file extension or it has not been determined yet.
+
+ See: `:meth:`.fetch_ext` to try and force setting the file extension by content type.
+ """
+ return self._ext.removeprefix(".") if self._ext else None
+
+ @property
+ def dimensions(self) -> tuple[int, int] | None:
+ """A property that returns the dimensions of the asset if it supports custom dimensions or ``None``.
+
+ See: :meth:`.set_dimensions` for more information.
+ """
+ return self._dimensions
+
+ def set_dimensions(self, width: int, height: int) -> None:
+ """Set the dimensions of the asset for saving or reading.
+
+ By default all assets that support custom dimensions already have pre-defined values set.
+ If custom dimensions are **not** supported, a warning will be logged and the default dimensions will be used.
+
+ .. warning::
+ If you need to custom dimensions for an asset that supports it you should use this method **before**
+ calling :meth:`.save` or :meth:`.read`.
+
+ Examples
+ --------
+ .. code:: python3
+
+ # Fetch a game and set the box art dimensions to 720x960; which is a 3:4 aspect ratio.
+
+ game: twitchio.Game = await client.fetch_game("League of Legends")
+ game.box_art.set_dimensions(720, 960)
+
+ # Call read or save...
+ await game.box_art.save()
+
+
+ Parameters
+ ----------
+ width: int
+ The width of the asset.
+ height: int
+ The height of the asset.
+ """
+ if not self._dimensions:
+ logger.warning("Setting dimensions on asset %r is not supported.", self)
+ return
+
+ self._dimensions = (width, height)
+ self._url = self._original_url.format(width=width, height=height)
+
+ def url_for(self, width: int, height: int) -> str:
+ """Return a new URL for the asset with the specified dimensions.
+
+ .. note::
+ This method does not return new dimensions on assets that do not support it.
+
+ .. warning::
+ This method does not set dimensions for saving or reading.
+ If you need custom dimensions for an asset that supports it see: :meth:`.set_dimensions`.
+
+ Parameters
+ ----------
+ width: int
+ The width of the asset.
+ height: int
+ The height of the asset.
+
+ Returns
+ -------
+ str
+ The new URL for the asset with the specified dimensions or
+ the original URL if the asset does not support custom dimensions.
+ """
+ if not self._dimensions:
+ logger.warning("Setting dimensions on asset %r is not supported.", self)
+ return self._url
+
+ return self._original_url.format(width=width, height=height)
+
+ def _set_ext(self, headers: dict[str, str]) -> str | None:
+ content: str | None = headers.get("Content-Type")
+ if not content or not content.startswith("image/"):
+ return None
+
+ ext: str = content.split("/")[1]
+ self._ext = f".{ext}"
+
+ return self._ext
+
+ async def fetch_ext(self) -> str | None:
+ """Fetch and set the file extension of the asset by content type.
+
+ This method will try to fetch the file extension of the asset by making a HEAD request to the asset's URL.
+ If the content type is not recognized or the request fails, the file extension will remain unchanged.
+
+ For the majority of cases you should not need to use this method.
+
+ .. warning::
+ This method sets the file extension of the asset by content type.
+
+ Returns
+ -------
+ str | None
+ The file extension of the asset determined by the content type or ``None`` if it could not be determined.
+ """
+ try:
+ headers: dict[str, str] = await self._http._request_asset_head(self.url)
+ except HTTPException:
+ return None
+
+ return self._set_ext(headers)
+
+ async def save(
+ self,
+ fp: str | os.PathLike[Any] | io.BufferedIOBase | None = None,
+ seek_start: bool = True,
+ force_extension: bool = True,
+ ) -> int:
+ """Save this asset to a file or file-like object.
+
+ If ``fp`` is ``None``, the asset will be saved to the current working directory with the
+ asset's default qualified name.
+
+ Examples
+ --------
+
+ **Save with defaults**
+
+ .. code:: python3
+
+ # Fetch a game and save the box art to the current working directory with the asset's default name.
+
+ game: twitchio.Game = await client.fetch_game("League of Legends")
+ await game.box_art.save()
+
+
+ **Save with a custom name**
+
+ .. code:: python3
+
+ # Fetch a game and save the box art to the current working directory with a custom name.
+
+ game: twitchio.Game = await client.fetch_game("League of Legends")
+ await game.box_art.save("custom_name.png")
+
+
+ **Save with a file-like object**
+
+ .. code:: python3
+
+ # Fetch a game and save the box art to a file-like object.
+
+ game: twitchio.Game = await client.fetch_game("League of Legends")
+ with open("custom_name.png", "wb") as fp:
+ await game.box_art.save(fp)
+
+
+ Parameters
+ -----------
+ fp: str | os.PathLike | io.BufferedIOBase | None
+ The file path or file-like object to save the asset to.
+
+ If ``None``, the asset will be saved to the current working directory with the asset's qualified name.
+
+ If ``fp`` is a directory, the asset will be saved to the directory with the asset's qualified name.
+
+ Defaults to ``None``.
+ seek_start: bool
+ Whether to seek to the start of the file after successfully writing data. Defaults to ``True``.
+ force_extension: bool
+ Whether to force the file extension of the asset to match the content type. Defaults to ``True``.
+
+ If no file extension was provided with ``fp`` setting ``force_extension`` to ``True``
+ will force the file extension to match the content type provided by Twitch.
+
+ Returns
+ -------
+ int
+ The number of bytes written to the file or file-like object.
+
+ Raises
+ ------
+ FileNotFoundError
+ Raised when ``fp`` is a directory or path to directory which can not be found or accessed.
+ """
+ data: io.BytesIO = await self.read()
+ written: int = 0
+
+ if isinstance(fp, io.BufferedIOBase):
+ written = fp.write(data.read())
+
+ if seek_start:
+ fp.seek(0)
+
+ return written
+
+ if not fp:
+ fp = pathlib.Path.cwd() / self.qualified_name
+
+ elif pathlib.Path(fp).is_dir():
+ fp = pathlib.Path(fp) / (self.qualified_name if force_extension else self.name)
+
+ elif isinstance(fp, str) and force_extension:
+ fp = f"{fp}{self._ext or ''}"
+
+ with open(fp, "wb") as new:
+ written = new.write(data.read())
+
+ return written
+
+ async def read(self, *, seek_start: bool = True, chunk_size: int = 1024) -> io.BytesIO:
+ """Read from the asset and return an :class:`io.BytesIO` buffer.
+
+ You can use this method to save the asset to memory and use it later.
+
+ Examples
+ --------
+ .. code:: python3
+
+ # Fetch a game and read the box art to memory.
+
+ game: twitchio.Game = await client.fetch_game("League of Legends")
+ data: io.BytesIO = await game.box_art.read()
+
+ # Later...
+ some_bytes = data.read()
+
+
+ Parameters
+ ----------
+ seek_start: bool
+ Whether to seek to the start of the buffer after successfully writing data. Defaults to ``True``.
+ chunk_size: int
+ The size of the chunk to use when reading from the asset. Defaults to ``1024``.
+
+ Returns
+ -------
+ io.BytesIO
+ A bytes buffer containing the asset's data.
+ """
+ fp: io.BytesIO = io.BytesIO()
+
+ async for chunk in self._http._request_asset(self, chunk_size=chunk_size):
+ fp.write(chunk)
+
+ if seek_start:
+ fp.seek(0)
+
+ return fp
diff --git a/twitchio/authentication/__init__.py b/twitchio/authentication/__init__.py
new file mode 100644
index 00000000..87a7a8d4
--- /dev/null
+++ b/twitchio/authentication/__init__.py
@@ -0,0 +1,28 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from .oauth import OAuth as OAuth
+from .payloads import *
+from .scopes import Scopes as Scopes
+from .tokens import ManagedHTTPClient as ManagedHTTPClient
diff --git a/twitchio/authentication/oauth.py b/twitchio/authentication/oauth.py
new file mode 100644
index 00000000..247b4c72
--- /dev/null
+++ b/twitchio/authentication/oauth.py
@@ -0,0 +1,172 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import secrets
+import urllib.parse
+from typing import TYPE_CHECKING, ClassVar
+
+from ..http import HTTPClient, Route
+from ..utils import MISSING
+from .payloads import *
+
+
+if TYPE_CHECKING:
+ import aiohttp
+
+ from ..types_.responses import (
+ AuthorizationURLResponse,
+ ClientCredentialsResponse,
+ RefreshTokenResponse,
+ UserTokenResponse,
+ ValidateTokenResponse,
+ )
+ from .scopes import Scopes
+
+
+class OAuth(HTTPClient):
+ CONTENT_TYPE_HEADER: ClassVar[dict[str, str]] = {"Content-Type": "application/x-www-form-urlencoded"}
+
+ def __init__(
+ self,
+ *,
+ client_id: str,
+ client_secret: str,
+ redirect_uri: str | None = None,
+ scopes: Scopes | None = None,
+ session: aiohttp.ClientSession = MISSING,
+ ) -> None:
+ super().__init__(session=session, client_id=client_id)
+
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.redirect_uri = redirect_uri
+ self.scopes = scopes
+
+ async def validate_token(self, token: str, /) -> ValidateTokenPayload:
+ token = token.removeprefix("Bearer ").removeprefix("OAuth ")
+
+ headers: dict[str, str] = {"Authorization": f"OAuth {token}"}
+ route: Route = Route("GET", "/oauth2/validate", use_id=True, headers=headers)
+
+ data: ValidateTokenResponse = await self.request_json(route)
+ return ValidateTokenPayload(data)
+
+ async def refresh_token(self, refresh_token: str, /) -> RefreshTokenPayload:
+ params = self._create_params(
+ {
+ "grant_type": "refresh_token",
+ "refresh_token": urllib.parse.quote(refresh_token, safe=""),
+ }
+ )
+
+ route: Route = Route("POST", "/oauth2/token", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
+ data: RefreshTokenResponse = await self.request_json(route)
+
+ return RefreshTokenPayload(data)
+
+ async def user_access_token(self, code: str, /, *, redirect_uri: str | None = None) -> UserTokenPayload:
+ redirect = redirect_uri or self.redirect_uri
+ if not redirect:
+ raise ValueError('"redirect_uri" is a required parameter or attribute which is missing.')
+
+ params = self._create_params(
+ {
+ "code": code,
+ "grant_type": "authorization_code",
+ "redirect_uri": redirect,
+ # "scope": " ".join(SCOPES), #TODO
+ # "state": #TODO
+ }
+ )
+
+ route: Route = Route("POST", "/oauth2/token", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
+ data: UserTokenResponse = await self.request_json(route)
+
+ return UserTokenPayload(data)
+
+ async def revoke_token(self, token: str, /) -> None:
+ params = self._create_params({"token": token})
+
+ route: Route = Route("POST", "/oauth2/revoke", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
+ await self.request_json(route)
+
+ async def client_credentials_token(self) -> ClientCredentialsPayload:
+ params = self._create_params({"grant_type": "client_credentials"})
+
+ route: Route = Route("POST", "/oauth2/token", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
+ data: ClientCredentialsResponse = await self.request_json(route)
+
+ return ClientCredentialsPayload(data)
+
+ def get_authorization_url(
+ self,
+ *,
+ scopes: Scopes | None = None,
+ state: str | None = None,
+ redirect_uri: str | None = None,
+ force_verify: bool = False,
+ ) -> AuthorizationURLPayload:
+ redirect = redirect_uri or self.redirect_uri
+ if not redirect:
+ raise ValueError('"redirect_uri" is a required parameter or attribute which is missing.')
+
+ scopes = scopes or self.scopes
+ if not scopes:
+ raise ValueError('"scopes" is a required parameter or attribute which is missing.')
+
+ if state is None:
+ state = secrets.token_urlsafe(32)
+
+ params = {
+ "client_id": self.client_id,
+ "redirect_uri": urllib.parse.quote(redirect),
+ "response_type": "code",
+ "scope": scopes.urlsafe(),
+ "force_verify": "true" if force_verify else "false",
+ "state": state,
+ }
+
+ route: Route = Route("GET", "/oauth2/authorize", use_id=True, params=params)
+ data: AuthorizationURLResponse = {
+ "url": route.url,
+ "client_id": self.client_id,
+ "redirect_uri": redirect,
+ "response_type": "code",
+ "scopes": scopes.selected,
+ "force_verify": force_verify,
+ "state": state,
+ }
+
+ payload: AuthorizationURLPayload = AuthorizationURLPayload(data)
+ return payload
+
+ def _create_params(self, extra_params: dict[str, str]) -> dict[str, str]:
+ params = {
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ }
+ params.update(extra_params)
+ return params
diff --git a/twitchio/authentication/payloads.py b/twitchio/authentication/payloads.py
new file mode 100644
index 00000000..cfee2bef
--- /dev/null
+++ b/twitchio/authentication/payloads.py
@@ -0,0 +1,125 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator, Mapping
+from typing import TYPE_CHECKING, Any
+
+
+if TYPE_CHECKING:
+ from ..types_.responses import *
+
+
+__all__ = (
+ "AuthorizationURLPayload",
+ "ClientCredentialsPayload",
+ "RefreshTokenPayload",
+ "UserTokenPayload",
+ "ValidateTokenPayload",
+)
+
+
+class BasePayload(Mapping[str, Any]):
+ __slots__ = ("raw_data",)
+
+ def __init__(self, raw: OAuthResponses, /) -> None:
+ self.raw_data = raw
+
+ def __getitem__(self, key: str) -> Any:
+ return self.raw_data[key] # type: ignore
+
+ def __iter__(self) -> Iterator[str]:
+ return iter(self.raw_data)
+
+ def __len__(self) -> int:
+ return len(self.raw_data)
+
+
+class RefreshTokenPayload(BasePayload):
+ __slots__ = ("access_token", "expires_in", "refresh_token", "scope", "token_type")
+
+ def __init__(self, raw: RefreshTokenResponse, /) -> None:
+ super().__init__(raw)
+
+ self.access_token: str = raw["access_token"]
+ self.refresh_token: str = raw["refresh_token"]
+ self.expires_in: int = raw["expires_in"]
+ self.scope: str | list[str] = raw["scope"]
+ self.token_type: str = raw["token_type"]
+
+
+class ValidateTokenPayload(BasePayload):
+ __slots__ = ("client_id", "expires_in", "login", "scopes", "user_id")
+
+ def __init__(self, raw: ValidateTokenResponse, /) -> None:
+ super().__init__(raw)
+
+ self.client_id: str = raw["client_id"]
+ self.login: str | None = raw.get("login", None)
+ self.scopes: list[str] = raw["scopes"]
+ self.user_id: str | None = raw.get("user_id", None)
+ self.expires_in: int = raw["expires_in"]
+
+
+class UserTokenPayload(BasePayload):
+ __slots__ = ("access_token", "expires_in", "refresh_token", "scope", "token_type")
+
+ def __init__(self, raw: UserTokenResponse, /) -> None:
+ super().__init__(raw)
+
+ self.access_token: str = raw["access_token"]
+ self.refresh_token: str = raw["refresh_token"]
+ self.expires_in: int = raw["expires_in"]
+ self.scope: str | list[str] = raw["scope"]
+ self.token_type: str = raw["token_type"]
+
+
+class ClientCredentialsPayload(BasePayload):
+ __slots__ = ("access_token", "expires_in", "token_type")
+
+ def __init__(self, raw: ClientCredentialsResponse, /) -> None:
+ super().__init__(raw)
+
+ self.access_token: str = raw["access_token"]
+ self.expires_in: int = raw["expires_in"]
+ self.token_type: str = raw["token_type"]
+
+
+class AuthorizationURLPayload(BasePayload):
+ __slots__ = ("client_id", "force_verify", "redirect_uri", "response_type", "scopes", "state", "url")
+
+ def __init__(self, raw: AuthorizationURLResponse, /) -> None:
+ super().__init__(raw)
+
+ self.url: str = raw["url"]
+ self.client_id: str = raw["client_id"]
+ self.redirect_uri: str = raw["redirect_uri"]
+ self.response_type: str = raw["response_type"]
+ self.scopes: list[str] = raw["scopes"]
+ self.force_verify: bool = raw["force_verify"]
+ self.state: str = raw["state"]
+
+ def __str__(self) -> str:
+ return self.url
diff --git a/twitchio/authentication/scopes.py b/twitchio/authentication/scopes.py
new file mode 100644
index 00000000..2af22b94
--- /dev/null
+++ b/twitchio/authentication/scopes.py
@@ -0,0 +1,401 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import urllib.parse
+from typing import TYPE_CHECKING
+
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Iterator
+
+
+class _scope_property:
+ def __set_name__(self, owner: type[Scopes], name: str) -> None:
+ self._name = name
+
+ def __get__(self, *_: object) -> _scope_property:
+ return self
+
+ def __set__(self, instance: Scopes, value: bool) -> None:
+ if value is True:
+ instance._selected.add(self)
+ elif value is False:
+ instance._selected.discard(self)
+ else:
+ raise TypeError(f"Expected bool for scope, got {type(value).__name__}")
+
+ def __str__(self) -> str:
+ return self._name.replace("_", ":", 2)
+
+ def quoted(self) -> str:
+ return urllib.parse.quote(str(self))
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def value(self) -> str:
+ return str(self)
+
+ def __hash__(self) -> int:
+ return hash(self._name)
+
+ def __eq__(self, other: object, /) -> bool:
+ if not isinstance(other, (_scope_property, str)):
+ return NotImplemented
+
+ return str(self) == str(other)
+
+
+class _ScopeMeta(type):
+ def __setattr__(self, name: str, value: object, /) -> None:
+ raise AttributeError("Cannot set the value of a Scope property.")
+
+ def __delattr__(self, name: str, /) -> None:
+ raise AttributeError("Cannot delete the value of a Scope property.")
+
+
+class Scopes(metaclass=_ScopeMeta):
+ """The Scopes class is a helper utility class to help with selecting and formatting scopes for use in Twitch OAuth.
+
+ The Scopes class can be initialised and used a few different ways:
+
+ - Passing a ``list[str]`` of scopes to the constructor which can be in the same format as seen on Twitch. E.g. ``["user:read:email", ...]``.
+
+ - Passing ``Keyword-Arguments`` to the constructor. E.g. ``user_read_email=True``
+
+ - Or Constructing the class without passing anything and giving each required scope a bool when needed.
+
+ - There is also a classmethod :meth:`.all` which selects all scopes for you.
+
+
+
+ All scopes on this class are special descriptors.
+
+ Attributes
+ ----------
+ analytics_read_extensions
+ Equivalent to the ``analytics:read:extensions`` scope on Twitch.
+ analytics_read_games
+ Equivalent to the ``analytics:read:games`` scope on Twitch.
+ bits_read
+ Equivalent to the ``bits:read`` scope on Twitch.
+ channel_manage_ads
+ Equivalent to the ``channel:manage:ads`` scope on Twitch.
+ channel_read_ads
+ Equivalent to the ``channel:read:ads`` scope on Twitch.
+ channel_manage_broadcast
+ Equivalent to the ``channel:manage:broadcast`` scope on Twitch.
+ channel_read_charity
+ Equivalent to the ``channel:read:charity`` scope on Twitch.
+ channel_edit_commercial
+ Equivalent to the ``channel:edit:commercial`` scope on Twitch.
+ channel_read_editors
+ Equivalent to the ``channel:read:editors`` scope on Twitch.
+ channel_manage_extensions
+ Equivalent to the ``channel:manage:extensions`` scope on Twitch.
+ channel_read_goals
+ Equivalent to the ``channel:read:goals`` scope on Twitch.
+ channel_read_guest_star
+ Equivalent to the ``channel:read:guest:star`` scope on Twitch.
+ channel_manage_guest_star
+ Equivalent to the ``channel:manage:guest:star`` scope on Twitch.
+ channel_read_hype_train
+ Equivalent to the ``channel:read:hype:train`` scope on Twitch.
+ channel_manage_moderators
+ Equivalent to the ``channel:manage:moderators`` scope on Twitch.
+ channel_read_polls
+ Equivalent to the ``channel:read:polls`` scope on Twitch.
+ channel_manage_polls
+ Equivalent to the ``channel:manage:polls`` scope on Twitch.
+ channel_read_predictions
+ Equivalent to the ``channel:read:predictions`` scope on Twitch.
+ channel_manage_predictions
+ Equivalent to the ``channel:manage:predictions`` scope on Twitch.
+ channel_manage_raids
+ Equivalent to the ``channel:manage:raids`` scope on Twitch.
+ channel_read_redemptions
+ Equivalent to the ``channel:read:redemptions`` scope on Twitch.
+ channel_manage_redemptions
+ Equivalent to the ``channel:manage:redemptions`` scope on Twitch.
+ channel_manage_schedule
+ Equivalent to the ``channel:manage:schedule`` scope on Twitch.
+ channel_read_stream_key
+ Equivalent to the ``channel:read:stream:key`` scope on Twitch.
+ channel_read_subscriptions
+ Equivalent to the ``channel:read:subscriptions`` scope on Twitch.
+ channel_manage_videos
+ Equivalent to the ``channel:manage:videos`` scope on Twitch.
+ channel_read_vips
+ Equivalent to the ``channel:read:vips`` scope on Twitch.
+ channel_manage_vips
+ Equivalent to the ``channel:manage:vips`` scope on Twitch.
+ clips_edit
+ Equivalent to the ``clips:edit`` scope on Twitch.
+ moderation_read
+ Equivalent to the ``moderation:read`` scope on Twitch.
+ moderator_manage_announcements
+ Equivalent to the ``moderator:manage:announcements`` scope on Twitch.
+ moderator_manage_automod
+ Equivalent to the ``moderator:manage:automod`` scope on Twitch.
+ moderator_read_automod_settings
+ Equivalent to the ``moderator:read:automod:settings`` scope on Twitch.
+ moderator_manage_automod_settings
+ Equivalent to the ``moderator:manage:automod:settings`` scope on Twitch.
+ moderator_manage_banned_users
+ Equivalent to the ``moderator:manage:banned:users`` scope on Twitch.
+ moderator_read_blocked_terms
+ Equivalent to the ``moderator:read:blocked:terms`` scope on Twitch.
+ moderator_manage_blocked_terms
+ Equivalent to the ``moderator:manage:blocked:terms`` scope on Twitch.
+ moderator_manage_chat_messages
+ Equivalent to the ``moderator:manage:chat:messages`` scope on Twitch.
+ moderator_read_chat_settings
+ Equivalent to the ``moderator:read:chat:settings`` scope on Twitch.
+ moderator_manage_chat_settings
+ Equivalent to the ``moderator:manage:chat:settings`` scope on Twitch.
+ moderator_read_chatters
+ Equivalent to the ``moderator:read:chatters`` scope on Twitch.
+ moderator_read_followers
+ Equivalent to the ``moderator:read:followers`` scope on Twitch.
+ moderator_read_guest_star
+ Equivalent to the ``moderator:read:guest:star`` scope on Twitch.
+ moderator_manage_guest_star
+ Equivalent to the ``moderator:manage:guest:star`` scope on Twitch.
+ moderator_read_shield_mode
+ Equivalent to the ``moderator:read:shield:mode`` scope on Twitch.
+ moderator_manage_shield_mode
+ Equivalent to the ``moderator:manage:shield:mode`` scope on Twitch.
+ moderator_read_shoutouts
+ Equivalent to the ``moderator:read:shoutouts`` scope on Twitch.
+ moderator_manage_shoutouts
+ Equivalent to the ``moderator:manage:shoutouts`` scope on Twitch.
+ moderator_read_unban_requests
+ Equivalent to the ``moderator:read:unban:requests`` scope on Twitch.
+ moderator_manage_unban_requests
+ Equivalent to the ``moderator:manage:unban:requests`` scope on Twitch.
+ moderator_read_warnings
+ Equivalent to the ``moderator:read:warnings`` scope on Twitch.
+ moderator_manage_warnings
+ Equivalent to the ``moderator:manage:warnings`` scope on Twitch.
+ moderator_read_moderators
+ Equivalent to the ``moderator:read:moderators`` scope on Twitch.
+ moderator_read_vips
+ Equivalent to the ``moderator:read:vips`` scope on Twitch.
+ user_edit
+ Equivalent to the ``user:edit`` scope on Twitch.
+ user_edit_follows
+ Equivalent to the ``user:edit:follows`` scope on Twitch.
+ user_read_blocked_users
+ Equivalent to the ``user:read:blocked:users`` scope on Twitch.
+ user_manage_blocked_users
+ Equivalent to the ``user:manage:blocked:users`` scope on Twitch.
+ user_read_broadcast
+ Equivalent to the ``user:read:broadcast`` scope on Twitch.
+ user_manage_chat_color
+ Equivalent to the ``user:manage:chat:color`` scope on Twitch.
+ user_read_email
+ Equivalent to the ``user:read:email`` scope on Twitch.
+ user_read_follows
+ Equivalent to the ``user:read:follows`` scope on Twitch.
+ user_read_moderated_channels
+ Equivalent to the ``user:read:moderated:channels`` scope on Twitch.
+ user_read_subscriptions
+ Equivalent to the ``user:read:subscriptions`` scope on Twitch.
+ user_read_emotes
+ Equivalent to the ``user:read:emotes`` scope on Twitch.
+ user_manage_whispers
+ Equivalent to the ``user:manage:whispers`` scope on Twitch.
+ user_read_whispers
+ Equivalent to the ``user:read:whispers`` scope on Twitch.
+ channel_bot
+ Equivalent to the ``channel:bot`` scope on Twitch.
+ channel_moderate
+ Equivalent to the ``channel:moderate`` scope on Twitch.
+ chat_edit
+ Equivalent to the ``chat:edit`` scope on Twitch.
+ chat_read
+ Equivalent to the ``chat:read`` scope on Twitch.
+ user_bot
+ Equivalent to the ``user:bot`` scope on Twitch.
+ user_read_chat
+ Equivalent to the ``user:read:chat`` scope on Twitch.
+ user_write_chat
+ Equivalent to the ``user:write:chat`` scope on Twitch.
+ whispers_read
+ Equivalent to the ``whispers:read`` scope on Twitch.
+ whispers_edit
+ Equivalent to the ``whispers:edit`` scope on Twitch.
+ """
+
+ __slots__ = ("_selected",)
+
+ analytics_read_extensions = _scope_property()
+ analytics_read_games = _scope_property()
+ bits_read = _scope_property()
+ channel_manage_ads = _scope_property()
+ channel_read_ads = _scope_property()
+ channel_manage_broadcast = _scope_property()
+ channel_read_charity = _scope_property()
+ channel_edit_commercial = _scope_property()
+ channel_read_editors = _scope_property()
+ channel_manage_extensions = _scope_property()
+ channel_read_goals = _scope_property()
+ channel_read_guest_star = _scope_property()
+ channel_manage_guest_star = _scope_property()
+ channel_read_hype_train = _scope_property()
+ channel_manage_moderators = _scope_property()
+ channel_read_polls = _scope_property()
+ channel_manage_polls = _scope_property()
+ channel_read_predictions = _scope_property()
+ channel_manage_predictions = _scope_property()
+ channel_manage_raids = _scope_property()
+ channel_read_redemptions = _scope_property()
+ channel_manage_redemptions = _scope_property()
+ channel_manage_schedule = _scope_property()
+ channel_read_stream_key = _scope_property()
+ channel_read_subscriptions = _scope_property()
+ channel_manage_videos = _scope_property()
+ channel_read_vips = _scope_property()
+ channel_manage_vips = _scope_property()
+ clips_edit = _scope_property()
+ moderation_read = _scope_property()
+ moderator_manage_announcements = _scope_property()
+ moderator_manage_automod = _scope_property()
+ moderator_read_automod_settings = _scope_property()
+ moderator_manage_automod_settings = _scope_property()
+ moderator_manage_banned_users = _scope_property()
+ moderator_read_blocked_terms = _scope_property()
+ moderator_manage_blocked_terms = _scope_property()
+ moderator_manage_chat_messages = _scope_property()
+ moderator_read_chat_settings = _scope_property()
+ moderator_manage_chat_settings = _scope_property()
+ moderator_read_chatters = _scope_property()
+ moderator_read_followers = _scope_property()
+ moderator_read_guest_star = _scope_property()
+ moderator_manage_guest_star = _scope_property()
+ moderator_read_shield_mode = _scope_property()
+ moderator_manage_shield_mode = _scope_property()
+ moderator_read_shoutouts = _scope_property()
+ moderator_manage_shoutouts = _scope_property()
+ moderator_read_unban_requests = _scope_property()
+ moderator_manage_unban_requests = _scope_property()
+ moderator_read_warnings = _scope_property()
+ moderator_manage_warnings = _scope_property()
+ moderator_read_moderators = _scope_property()
+ moderator_read_vips = _scope_property()
+ user_edit_broadcast = _scope_property()
+ user_edit = _scope_property()
+ user_edit_follows = _scope_property()
+ user_read_blocked_users = _scope_property()
+ user_manage_blocked_users = _scope_property()
+ user_read_broadcast = _scope_property()
+ user_manage_chat_color = _scope_property()
+ user_read_email = _scope_property()
+ user_read_follows = _scope_property()
+ user_read_moderated_channels = _scope_property()
+ user_read_subscriptions = _scope_property()
+ user_read_emotes = _scope_property()
+ user_manage_whispers = _scope_property()
+ user_read_whispers = _scope_property()
+ channel_bot = _scope_property()
+ channel_moderate = _scope_property()
+ chat_edit = _scope_property()
+ chat_read = _scope_property()
+ user_bot = _scope_property()
+ user_read_chat = _scope_property()
+ user_write_chat = _scope_property()
+ whispers_read = _scope_property()
+ whispers_edit = _scope_property()
+
+ def __init__(self, scopes: Iterable[str | _scope_property] | None = None, /, **kwargs: bool) -> None:
+ if scopes is None:
+ scopes = []
+ self._selected: set[_scope_property] = set()
+
+ prop: _scope_property
+
+ for scope in scopes:
+ if isinstance(scope, str):
+ prop = getattr(self, scope.replace(":", "_"))
+ elif isinstance(scope, _scope_property): # type: ignore
+ prop = scope
+ else:
+ raise TypeError(f"Invalid scope provided: {type(scope)} is not a valid scope.")
+
+ self._selected.add(prop)
+
+ for key, value in kwargs.items():
+ prop = getattr(self, key)
+
+ if value is True:
+ self._selected.add(prop)
+ elif value is False:
+ self._selected.discard(prop)
+ else:
+ raise TypeError(f'Expected bool for scope kwarg "{key}", got {type(value).__name__}')
+
+ def __iter__(self) -> Iterator[str]:
+ return iter([str(scope) for scope in self._selected])
+
+ def __repr__(self) -> str:
+ return f""
+
+ def __str__(self) -> str:
+ return self.urlsafe()
+
+ def __contains__(self, scope: _scope_property | str, /) -> bool:
+ if isinstance(scope, str):
+ return any(s.value == scope for s in self._selected)
+
+ return scope in self._selected
+
+ def urlsafe(self, *, unquote: bool = False) -> str:
+ """Method which returns a URL-Safe formatted ``str`` of selected scopes.
+
+ The string returned by this method is safe to use in browsers etc.
+
+ Parameters
+ ----------
+ unqoute: bool
+ If this is ``True``, this will return scopes without URL quoting, E.g. as ``user:read:email+channel:bot``
+ compared to ``user%3Aread%3Aemail+channel%3Abot``. Defaults to ``False``.
+ """
+ return "+".join([scope.value if unquote else scope.quoted() for scope in self._selected])
+
+ @property
+ def selected(self) -> list[str]:
+ """Property that returns a ``list[str]`` of selected scopes.
+
+ This is not URL-Safe. See: :meth:`.urlsafe` for a method which returns a URL-Safe string.
+ """
+ return list(self)
+
+ @classmethod
+ def all(cls) -> Scopes:
+ """Classmethod which creates this :class:`.Scopes` object with all scopes selected."""
+ return cls([scope for scope in cls.__dict__.values() if isinstance(scope, _scope_property)])
diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py
new file mode 100644
index 00000000..6c3040a1
--- /dev/null
+++ b/twitchio/authentication/tokens.py
@@ -0,0 +1,335 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+import asyncio
+import datetime
+import json
+import logging
+from typing import TYPE_CHECKING, Any, TypeVar
+
+import aiohttp
+
+from twitchio.http import Route
+from twitchio.types_.responses import RawResponse
+
+from ..backoff import Backoff
+from ..exceptions import HTTPException, InvalidTokenException
+from ..http import HTTPAsyncIterator, PaginatedConverter
+from ..types_.tokens import TokenMappingData
+from ..utils import MISSING
+from .oauth import OAuth
+from .payloads import ClientCredentialsPayload, ValidateTokenPayload
+from .scopes import Scopes
+
+
+if TYPE_CHECKING:
+ from ..types_.tokens import TokenMapping
+ from .payloads import RefreshTokenPayload
+
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+
+T = TypeVar("T")
+
+
+class ManagedHTTPClient(OAuth):
+ def __init__(
+ self,
+ *,
+ client_id: str,
+ client_secret: str,
+ redirect_uri: str | None = None,
+ scopes: Scopes | None = None,
+ session: aiohttp.ClientSession = MISSING,
+ nested_key: str | None = None,
+ ) -> None:
+ super().__init__(
+ client_id=client_id,
+ client_secret=client_secret,
+ redirect_uri=redirect_uri,
+ scopes=scopes,
+ session=session,
+ )
+ self.__isolated: OAuth = OAuth(
+ client_id=client_id,
+ client_secret=client_secret,
+ redirect_uri=redirect_uri,
+ scopes=scopes,
+ session=session,
+ )
+
+ self._tokens: TokenMapping = {}
+ self._app_token: str | None = None
+ self._nested_key: str | None = None
+
+ self._token_lock: asyncio.Lock = asyncio.Lock()
+ self._has_loaded: bool = False
+ self._backoff: Backoff = Backoff(base=3, maximum_time=90)
+
+ self._validated_event: asyncio.Event = asyncio.Event()
+ self._validate_task: asyncio.Task[None] | None = None
+
+ async def _attempt_refresh_on_add(self, token: str, refresh: str) -> ValidateTokenPayload:
+ logger.debug("Token was invalid when attempting to add it to the token manager. Attempting to refresh.")
+
+ try:
+ resp: RefreshTokenPayload = await self.__isolated.refresh_token(refresh)
+ except HTTPException as e:
+ msg: str = f'Token was invalid and cannot be refreshed. Please re-authenticate user with token: "{token}"'
+ raise InvalidTokenException(msg, token=token, refresh=refresh, type_="refresh", original=e)
+
+ try:
+ valid_resp: ValidateTokenPayload = await self.__isolated.validate_token(resp["access_token"])
+ except HTTPException as e:
+ msg: str = f'Refreshed token was invalid. Please re-authenticate user with token: "{token}"'
+ raise InvalidTokenException(msg, token=token, refresh=refresh, type_="token", original=e)
+
+ if not valid_resp.login or not valid_resp.user_id:
+ logger.info("Refreshed token is not a user token. Adding to TokenManager as an app token.")
+ self._app_token = resp.access_token
+
+ return valid_resp
+
+ self._tokens[valid_resp.user_id] = {
+ "user_id": valid_resp.user_id,
+ "token": resp.access_token,
+ "refresh": resp.refresh_token,
+ "last_validated": datetime.datetime.now().isoformat(),
+ }
+
+ logger.info('Token successfully added to TokenManager after refresh: "%s"', valid_resp.user_id)
+ return valid_resp
+
+ async def add_token(self, token: str, refresh: str) -> ValidateTokenPayload:
+ if not self._validate_task:
+ self._validate_task = asyncio.create_task(self.__validate_loop())
+
+ try:
+ resp: ValidateTokenPayload = await self.__isolated.validate_token(token)
+ except HTTPException as e:
+ if e.status != 401:
+ msg: str = "Token was invalid. Please check the token or re-authenticate user with a new token."
+ raise InvalidTokenException(msg, token=token, refresh=refresh, type_="token", original=e)
+
+ return await self._attempt_refresh_on_add(token, refresh)
+
+ if not resp.login or not resp.user_id:
+ logger.info("Added token is not a user token. Adding to TokenManager as an app token.")
+ self._app_token = token
+
+ return resp
+
+ self._tokens[resp.user_id] = {
+ "user_id": resp.user_id,
+ "token": token,
+ "refresh": refresh,
+ "last_validated": datetime.datetime.now().isoformat(),
+ }
+
+ logger.debug('Token successfully added to TokenManager: "%s"', resp.user_id)
+ return resp
+
+ def remove_token(self, user_id: str) -> TokenMappingData | None:
+ data: TokenMappingData | None = self._tokens.pop(user_id, None)
+ return data
+
+ def _find_token(self, route: Route) -> TokenMappingData | None | str:
+ token: str | None = route.headers.get("Authorization")
+ if token:
+ token = token.removeprefix("Bearer ").removeprefix("OAuth ")
+
+ if token == self._app_token:
+ return token
+
+ if route.token_for and not token:
+ scoped: TokenMappingData | None = self._tokens.get(route.token_for, None)
+ if scoped:
+ return scoped
+
+ for data in self._tokens.values():
+ if data["token"] == token:
+ return data
+
+ return token or self._app_token
+
+ async def request(self, route: Route) -> RawResponse | str | None:
+ old: TokenMappingData | None | str = self._find_token(route)
+ if old:
+ token: str = old if isinstance(old, str) else old["token"]
+ route.update_headers({"Authorization": f"Bearer {token}"})
+
+ try:
+ data: RawResponse | str | None = await super().request(route)
+ except HTTPException as e:
+ if not old or e.status != 401:
+ raise e
+
+ if e.extra.get("message", "").lower() != "invalid access token":
+ raise e
+
+ if isinstance(old, str):
+ payload: ClientCredentialsPayload = await self.client_credentials_token()
+ self._app_token = payload.access_token
+ route.update_headers({"Authorization": f"Bearer {payload.access_token}"})
+
+ return await self.request(route)
+
+ logger.debug('Token for "%s" was invalid or expired. Attempting to refresh token.', old["user_id"])
+ refresh: RefreshTokenPayload = await self.__isolated.refresh_token(old["refresh"])
+ logger.debug('Token for "%s" was successfully refreshed.', old["user_id"])
+
+ self._tokens[old["user_id"]] = {
+ "user_id": old["user_id"],
+ "token": refresh.access_token,
+ "refresh": refresh.refresh_token,
+ "last_validated": datetime.datetime.now().isoformat(),
+ }
+
+ route.update_headers({"Authorization": f"Bearer {refresh.access_token}"})
+ return await self.request(route)
+
+ return data
+
+ def request_paginated(
+ self,
+ route: Route,
+ max_results: int | None = None,
+ *,
+ converter: PaginatedConverter[T] | None = None,
+ nested_key: str | None = None,
+ ) -> HTTPAsyncIterator[T]:
+ iterator: HTTPAsyncIterator[T] = HTTPAsyncIterator(
+ self,
+ route,
+ max_results,
+ converter=converter,
+ nested_key=nested_key,
+ )
+ return iterator
+
+ async def _revalidate_all(self) -> None:
+ logger.debug("Attempting to revalidate all tokens that have passed the timeout on %s.", self.__class__.__qualname__)
+
+ for data in self._tokens.copy().values():
+ last_validated: datetime.datetime = datetime.datetime.fromisoformat(data["last_validated"])
+ if last_validated + datetime.timedelta(minutes=60) > datetime.datetime.now():
+ continue
+
+ try:
+ await self.__isolated.validate_token(data["token"])
+ except HTTPException as e:
+ if e.status >= 500:
+ raise
+
+ logger.debug('Token for "%s" was invalid or expired. Attempting to refresh token.', data["user_id"])
+
+ try:
+ refresh: RefreshTokenPayload = await self.__isolated.refresh_token(data["refresh"])
+ except HTTPException as e:
+ if e.status >= 500:
+ raise
+
+ self._tokens.pop(data["user_id"], None)
+ logger.warning('Token for "%s" was invalid and could not be refreshed.', data["user_id"])
+ continue
+
+ logger.debug('Token for "%s" was successfully refreshed.', data["user_id"])
+
+ self._tokens[data["user_id"]] = {
+ "user_id": data["user_id"],
+ "token": refresh.access_token,
+ "refresh": refresh.refresh_token,
+ "last_validated": datetime.datetime.now().isoformat(),
+ }
+
+ async def __validate_loop(self) -> None:
+ logger.debug("Started the token validation loop on %s.", self.__class__.__qualname__)
+
+ while True:
+ self._validated_event.clear()
+
+ try:
+ await self._revalidate_all()
+ except (ConnectionError, aiohttp.ClientConnectorError, HTTPException) as e:
+ wait: float = self._backoff.calculate()
+ logger.debug("Unable to reach Twitch to revalidate tokens: %s. Retrying in %s's", e, wait)
+
+ await asyncio.sleep(wait)
+ continue
+
+ self._validated_event.set()
+ await asyncio.sleep(60)
+
+ def cleanup(self) -> None:
+ self._tokens.clear()
+
+ async def close(self) -> None:
+ if self._validate_task:
+ try:
+ self._validate_task.cancel()
+ except Exception:
+ pass
+
+ self._validate_task = None
+
+ await super().close()
+ await self.__isolated.close()
+
+ async def save(self, name: str | None = None) -> None:
+ if not self._has_loaded:
+ return
+
+ name = name or ".tio.tokens.json"
+
+ with open(name, "w+", encoding="UTF-8") as fp:
+ json.dump(self._tokens, fp)
+
+ logger.info('Tokens from %s have been saved to: "%s".', self.__class__.__qualname__, name)
+
+ async def load_tokens(self, name: str | None = None) -> None:
+ name = name or ".tio.tokens.json"
+ data: dict[str, Any] = {}
+ failed: list[str] = []
+ loaded: int = 0
+
+ try:
+ with open(name, "r+", encoding="UTF-8") as fp:
+ data = json.load(fp)
+ except FileNotFoundError:
+ pass
+
+ for key, value in data.items():
+ try:
+ await self.add_token(token=value["token"], refresh=value["refresh"])
+ loaded += 1
+ except InvalidTokenException:
+ failed.append(key)
+
+ logger.info("Loaded %s tokens into the Token Manager.", loaded)
+ if failed:
+ msg: str = f"The following users tokens failed to load: {', '.join(failed)}"
+ logger.warning(msg)
+
+ self._has_loaded = True
diff --git a/twitchio/backoff.py b/twitchio/backoff.py
index 7634b486..6dc0d822 100644
--- a/twitchio/backoff.py
+++ b/twitchio/backoff.py
@@ -1,9 +1,7 @@
-# -*- coding: utf-8 -*-
-
"""
The MIT License (MIT)
-Copyright (c) 2015-2020 Rapptz
+Copyright (c) 2021-Present PythonistaGuild, EvieePy, Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
@@ -24,63 +22,60 @@
DEALINGS IN THE SOFTWARE.
"""
-import time
+from __future__ import annotations
+
import random
+from typing import TYPE_CHECKING
-class ExponentialBackoff:
- """An implementation of the exponential backoff algorithm
+if TYPE_CHECKING:
+ from collections.abc import Callable
- Provides a convenient interface to implement an exponential backoff
- for reconnecting or retrying transmissions in a distributed network.
- Once instantiated, the delay method will return the next interval to
- wait for when retrying a connection or transmission. The maximum
- delay increases exponentially with each retry up to a maximum of
- 2^10 * base, and is reset if no more attempts are needed in a period
- of 2^11 * base seconds.
+class Backoff:
+ """An implementation of an Exponential Backoff.
Parameters
----------
- base: :class:`int`
- The base delay in seconds. The first retry-delay will be up to
- this many seconds.
- integral: :class:`bool`
- Set to True if whole periods of base is desirable, otherwise any
- number in between may be returned.
+ base: int
+ The base time to multiply exponentially. Defaults to 1.
+ maximum_time: float
+ The maximum wait time. Defaults to 30.0
+ maximum_tries: Optional[int]
+ The amount of times to backoff before resetting. Defaults to 5. If set to None, backoff will run indefinitely.
"""
- def __init__(self, base=1, *, integral=False):
- self._base = base
-
- self._exp = 0
- self._max = 10
- self._reset_time = base * 2**11
- self._last_invocation = time.monotonic()
+ def __init__(self, *, base: int = 1, maximum_time: float = 30.0, maximum_tries: int | None = 5) -> None:
+ self._base: int = base
+ self._maximum_time: float = maximum_time
+ self._maximum_tries: int | None = maximum_tries
+ self._retries: int = 1
- # Use our own random instance to avoid messing with global one
rand = random.Random()
rand.seed()
- self._randfunc = rand.randrange if integral else rand.uniform
+ self._rand: Callable[[float, float], float] = rand.uniform
+
+ self._last_wait: float = 0
+
+ def calculate(self) -> float:
+ exponent = min((self._retries**2), self._maximum_time)
+ wait = self._rand(0, (self._base * 2) * exponent)
+
+ if wait <= self._last_wait:
+ wait = self._last_wait * 2
- def delay(self):
- """Compute the next delay
+ self._last_wait = wait
- Returns the next delay to wait according to the exponential
- backoff algorithm. This is a value between 0 and base * 2^exp
- where exponent starts off at 1 and is incremented at every
- invocation of this method up to a maximum of 10.
+ if wait > self._maximum_time:
+ wait = self._maximum_time
+ self._retries = 0
+ self._last_wait = 0
- If a period of more than base * 2^11 has passed since the last
- retry, the exponent is reset to 1.
- """
- invocation = time.monotonic()
- interval = invocation - self._last_invocation
- self._last_invocation = invocation
+ if self._maximum_tries and self._retries >= self._maximum_tries:
+ self._retries = 0
+ self._last_wait = 0
- if interval > self._reset_time:
- self._exp = 0
+ self._retries += 1
- self._exp = min(self._exp + 1, self._max)
- return self._randfunc(0, self._base * 2**self._exp)
+ return wait
diff --git a/twitchio/cache.py b/twitchio/cache.py
deleted file mode 100644
index 459c68e9..00000000
--- a/twitchio/cache.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import time
-import functools
-from typing import Hashable
-
-
-__all__ = ("TimedCache", "user_cache", "id_cache")
-
-
-class TimedCache(dict):
- def __init__(self, seconds: int):
- self.__timeout = seconds
- super().__init__()
-
- def _verify_cache(self):
- now = time.monotonic()
- to_remove = [key for (key, (_, exp)) in self.items() if now > (exp + self.__timeout)]
- for k in to_remove:
- del self[k]
-
- def __getitem__(self, key):
- self._verify_cache()
- return super().__getitem__(key)[0]
-
- def __setitem__(self, key, value):
- super().__setitem__(key, (value, time.monotonic()))
-
- def __contains__(self, key):
- self._verify_cache()
- return {a: b[0] for a, b in self.items()}.__contains__(key)
-
-
-def user_cache(timer=300):
- cache = TimedCache(timer)
-
- def wraps(func):
- @functools.wraps(func)
- async def _wraps(cls, names: list = None, ids: list = None, force=False, token=None):
- if not force:
- existing = []
- if names:
- existing.extend([cache[x] for x in names if x in cache])
-
- if ids:
- existing.extend([cache[x] for x in ids if x in cache])
-
- if len(existing) == (len(names) if names else 0) + (len(ids) if ids else 0):
- return existing
-
- values = await func(cls, names=names, ids=ids, token=token)
- for v in values:
- cache[v.id] = v
- cache[v.name] = v
-
- return values
-
- return _wraps
-
- return wraps
-
-
-def id_cache(timer=300):
- cache = TimedCache(timer)
-
- def wraps(func):
- @functools.wraps(func)
- def _wraps(cls, id: Hashable):
- if id in cache:
- return cache[id]
-
- value = func(cls, id)
- if value is not None:
- cache[id] = value
-
- return value
-
- return _wraps
-
- return wraps
diff --git a/twitchio/channel.py b/twitchio/channel.py
deleted file mode 100644
index 75b61379..00000000
--- a/twitchio/channel.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import datetime
-from typing import Optional, Union, Set, TYPE_CHECKING
-
-from .abcs import Messageable
-from .chatter import Chatter, PartialChatter
-from .models import BitsLeaderboard
-
-if TYPE_CHECKING:
- from .websocket import WSConnection
- from .user import User
-
-
-__all__ = ("Channel",)
-
-
-class Channel(Messageable):
- __slots__ = ("_name", "_ws", "_message")
-
- __messageable_channel__ = True
-
- def __init__(self, name: str, websocket: "WSConnection"):
- self._name = name
- self._ws = websocket
-
- def __eq__(self, other):
- return other.name == self._name
-
- def __hash__(self):
- return hash(self.name)
-
- def __repr__(self):
- return f""
-
- def _fetch_channel(self):
- return self # Abstract method
-
- def _fetch_websocket(self):
- return self._ws # Abstract method
-
- def _fetch_message(self):
- return self._message # Abstract method
-
- def _bot_is_mod(self):
- try:
- cache = self._ws._cache[self.name] # noqa
- except KeyError:
- return False
-
- for user in cache:
- if user.name == self._ws.nick:
- try:
- mod = user.is_mod
- except AttributeError:
- return False
-
- return mod
-
- @property
- def name(self) -> str:
- """The channel name."""
- return self._name
-
- @property
- def chatters(self) -> Optional[Set[Union[Chatter, PartialChatter]]]:
- """The channels current chatters."""
- try:
- chatters = self._ws._cache[self._name] # noqa
- except KeyError:
- return None
-
- return chatters
-
- def get_chatter(self, name: str) -> Optional[Union[Chatter, PartialChatter]]:
- """Retrieve a chatter from the channels user cache.
-
- Parameters
- -----------
- name: str
- The chatter's name to try and retrieve.
-
- Returns
- --------
- Union[:class:`twitchio.chatter.Chatter`, :class:`twitchio.chatter.PartialChatter`]
- Could be a :class:`twitchio.user.PartialChatter` depending on how the user joined the channel.
- Returns None if no user was found.
- """
- name = name.lower()
-
- try:
- cache = self._ws._cache[self._name] # noqa
- for chatter in cache:
- if chatter.name == name:
- return chatter
-
- return None
- except KeyError:
- return None
-
- async def user(self, force=False) -> "User":
- """|coro|
-
- Fetches the User from the api.
-
- Parameters
- -----------
- force: :class:`bool`
- Whether to force a fetch from the api, or try and pull from the cache. Defaults to `False`
-
- Returns
- --------
- :class:`twitchio.User` the user associated with the channel
- """
- return (await self._ws._client.fetch_users(names=[self._name], force=force))[0]
-
- async def fetch_bits_leaderboard(
- self, token: str, period: str = "all", user_id: int = None, started_at: datetime.datetime = None
- ) -> BitsLeaderboard:
- """|coro|
-
- Fetches the bits leaderboard for the channel. This requires an OAuth token with the bits:read scope.
-
- Parameters
- -----------
- token: :class:`str`
- the OAuth token with the bits:read scope
- period: Optional[:class:`str`]
- one of `day`, `week`, `month`, `year`, or `all`, defaults to `all`
- started_at: Optional[:class:`datetime.datetime`]
- the timestamp to start the period at. This is ignored if the period is `all`
- user_id: Optional[:class:`int`]
- the id of the user to fetch for
- """
- data = await self._ws._client._http.get_bits_board(token, period, user_id, started_at)
- return BitsLeaderboard(self._ws._client._http, data)
-
- async def whisper(self, content: str):
- """|coro|
-
- Whispers the user behind the channel. This will not work if the channel is the same as the one you are sending the message from.
-
- .. warning:
- Whispers are very unreliable on twitch. If you do not receive a whisper, this is probably twitch's fault, not the library's.
-
- Parameters
- -----------
- content: :class:`str`
- The content to send to the user
- """
- await self.send(f"/w {self.name} {content}")
diff --git a/twitchio/chatter.py b/twitchio/chatter.py
deleted file mode 100644
index 32c3b9e2..00000000
--- a/twitchio/chatter.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from typing import Optional, TYPE_CHECKING, Dict
-
-from .abcs import Messageable
-from .enums import PredictionEnum
-
-if TYPE_CHECKING:
- from .user import User
- from .websocket import WSConnection
-
-
-__all__ = ("PartialChatter", "Chatter")
-
-
-class PartialChatter(Messageable):
- __messageable_channel__ = False
-
- def __init__(self, websocket, **kwargs):
- self._name = kwargs.get("name")
- self._ws = websocket
- self._channel = kwargs.get("channel", self._name)
- self._message = kwargs.get("message")
-
- def __repr__(self):
- return f""
-
- def __eq__(self, other):
- return other.name == self.name and other.channel.name == other.channel.name
-
- def __hash__(self):
- return hash(self.name + self.channel.name)
-
- async def user(self) -> "User":
- """|coro|
-
- Fetches a :class:`twitchio.User` object based off the chatters channel name
-
- Returns
- --------
- :class:`twitchio.User`
- """
- return (await self._ws._client.fetch_users(names=[self.name]))[0]
-
- @property
- def name(self):
- """The users name"""
- return self._name
-
- @property
- def channel(self):
- """The channel associated with the user."""
- return self._channel
-
- def _fetch_channel(self):
- return self # Abstract method
-
- def _fetch_websocket(self):
- return self._ws # Abstract method
-
- def _fetch_message(self):
- return self._message # Abstract method
-
- def _bot_is_mod(self):
- return False
-
-
-class Chatter(PartialChatter):
- __slots__ = (
- "_name",
- "_channel",
- "_tags",
- "_badges",
- "_cached_badges",
- "_ws",
- "_id",
- "_turbo",
- "_sub",
- "_mod",
- "_display_name",
- "_colour",
- )
-
- __messageable_channel__ = False
-
- def __init__(self, websocket: "WSConnection", **kwargs):
- super(Chatter, self).__init__(websocket, **kwargs)
- self._tags = kwargs.get("tags", None)
- self._ws = websocket
-
- self._cached_badges: Optional[Dict[str, str]] = None
-
- if not self._tags:
- self._id = None
- self._badges = None
- self._turbo = None
- self._sub = None
- self._mod = None
- self._display_name = None
- self._colour = None
- self._vip = None
- return
-
- self._id = self._tags.get("user-id")
- self._badges = self._tags.get("badges")
- self._turbo = self._tags.get("turbo")
- self._sub = int(self._tags["subscriber"])
- self._mod = int(self._tags["mod"])
- self._display_name = self._tags["display-name"]
- self._colour = self._tags["color"]
- self._vip = int(self._tags.get("vip", 0))
-
- if self._badges:
- self._cached_badges = dict([badge.split("/") for badge in self._badges.split(",")])
-
- def __repr__(self):
- return f""
-
- def _bot_is_mod(self):
- cache = self._ws._cache[self._channel.name] # noqa
- for user in cache:
- if user.name == self._ws.nick:
- try:
- mod = user.is_mod
- except AttributeError:
- return False
-
- return mod
-
- @property
- def name(self) -> str:
- """The users name. This may be formatted differently than display name."""
- return self._name or (self.display_name and self.display_name.lower())
-
- @property
- def badges(self) -> dict:
- """The users badges."""
- return self._cached_badges.copy() if self._cached_badges else {}
-
- @property
- def display_name(self) -> Optional[str]:
- """The user's display name."""
- return self._display_name
-
- @property
- def id(self) -> Optional[str]:
- """The user's id."""
- return self._id
-
- @property
- def mention(self) -> str:
- """Mentions the users display name by prefixing it with '@'"""
- return f"@{self._display_name}"
-
- @property
- def colour(self) -> str:
- """The users colour. Alias to color."""
- return self._colour
-
- @property
- def color(self) -> str:
- """The users color."""
- return self.colour
-
- @property
- def is_broadcaster(self) -> bool:
- """A boolean indicating whether the User is the broadcaster of the current channel."""
-
- return "broadcaster" in self.badges
-
- @property
- def is_mod(self) -> bool:
- """A boolean indicating whether the User is a moderator of the current channel."""
- return True if self._mod == 1 else self.channel.name == self.name.lower()
-
- @property
- def is_vip(self) -> bool:
- """A boolean indicating whether the User is a VIP of the current channel."""
- return bool(self._vip)
-
- @property
- def is_turbo(self) -> Optional[bool]:
- """A boolean indicating whether the User is Turbo.
-
- Could be None if no Tags were received.
- """
- return self._turbo
-
- @property
- def is_subscriber(self) -> bool:
- """A boolean indicating whether the User is a subscriber of the current channel.
-
- .. note::
-
- changed in 2.1.0: return value is no longer optional. founders will now appear as subscribers
- """
- return bool(self._sub) or "founder" in self.badges
-
- @property
- def prediction(self) -> Optional[PredictionEnum]:
- """
- The users current prediction, if one exists.
-
- Returns
- --------
- Optional[:class:`twitchio.enums.PredictionEnum`]
- """
- if "blue-1" in self.badges:
- return PredictionEnum("blue-1")
-
- elif "pink-2" in self.badges:
- return PredictionEnum("pink-2")
-
- return None
-
-
-class WhisperChatter(PartialChatter):
- __messageable_channel__ = False
-
- def __init__(self, websocket: "WSConnection", **kwargs):
- super().__init__(websocket, **kwargs)
-
- def __repr__(self):
- return f""
-
- @property
- def channel(self):
- return None
-
- def _fetch_channel(self):
- return self # Abstract method
-
- def _fetch_websocket(self):
- return self._ws # Abstract method
-
- def _bot_is_mod(self):
- return False
diff --git a/twitchio/client.py b/twitchio/client.py
index 97460d06..c582364e 100644
--- a/twitchio/client.py
+++ b/twitchio/client.py
@@ -1,1128 +1,2418 @@
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
+from __future__ import annotations
+
import asyncio
-import inspect
-import warnings
import logging
-import traceback
-import sys
-from typing import Union, Callable, List, Optional, Tuple, Any, Coroutine, Dict
-from typing_extensions import Literal
+from collections import defaultdict
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, Literal, Self, Unpack
+
+from .authentication import ManagedHTTPClient, Scopes, UserTokenPayload
+from .eventsub.enums import SubscriptionType, TransportMethod
+from .eventsub.websockets import Websocket
+from .exceptions import HTTPException
+from .http import HTTPAsyncIterator
+from .models.bits import Cheermote, ExtensionTransaction
+from .models.ccls import ContentClassificationLabel
+from .models.channels import ChannelInfo
+from .models.chat import ChatBadge, ChatterColor, EmoteSet, GlobalEmote
+from .models.games import Game
+from .models.teams import Team
+from .payloads import EventErrorPayload
+from .user import ActiveExtensions, Extension, PartialUser, User
+from .utils import MISSING, EventWaiter, unwrap_function
+from .web import AiohttpAdapter
+from .web.utils import BaseAdapter
+
+
+if TYPE_CHECKING:
+ import datetime
+ from collections.abc import Awaitable, Callable, Coroutine
+
+ import aiohttp
+
+ from .authentication import ClientCredentialsPayload, ValidateTokenPayload
+ from .eventsub.subscriptions import SubscriptionPayload
+ from .http import HTTPAsyncIterator
+ from .models.clips import Clip
+ from .models.entitlements import Entitlement, EntitlementStatus
+ from .models.eventsub_ import EventsubSubscriptions
+ from .models.search import SearchChannel
+ from .models.streams import Stream, VideoMarkers
+ from .models.videos import Video
+ from .types_.eventsub import SubscriptionCreateTransport, SubscriptionResponse, _SubscriptionData
+ from .types_.options import ClientOptions, WaitPredicateT
+ from .types_.tokens import TokenMappingData
+
+
+logger: logging.Logger = logging.getLogger(__name__)
-from twitchio.errors import HTTPException
-from . import models
-from .websocket import WSConnection
-from .http import TwitchHTTP
-from .channel import Channel
-from .message import Message
-from .user import User, PartialUser, SearchUser
-from .cache import user_cache, id_cache
-__all__ = ("Client",)
+class Client:
+ """The TwitchIO Client.
-logger = logging.getLogger("twitchio.client")
+ The `Client` acts as an entry point to the Twitch API, EventSub and OAuth and serves as a base for chat-bots.
+ :class:`commands.Bot` inherits from this class and such should be treated as a `Client` with an in-built
+ commands extension.
-class Client:
- """TwitchIO Client object that is used to interact with the Twitch API and connect to Twitch IRC over websocket.
+ You don't need to :meth:`~.start` or :meth:`~.run` the `Client` to use it soley as a HTTP Wrapper,
+ but you must still :meth:`~.login` with this use case.
Parameters
- ------------
- token: :class:`str`
- An OAuth Access Token to login with on IRC and interact with the API.
- client_secret: Optional[:class:`str`]
- An optional application Client Secret used to generate Access Tokens automatically.
- initial_channels: Optional[Union[:class:`list`, :class:`tuple`, Callable]]
- An optional list, tuple or callable which contains channel names to connect to on startup.
- If this is a callable, it must return a list or tuple.
- loop: Optional[:class:`asyncio.AbstractEventLoop`]
- The event loop the client will use to run.
- heartbeat: Optional[float]
- An optional float in seconds to send a PING message to the server. Defaults to 30.0.
- retain_cache: Optional[bool]
- An optional bool that will retain the cache if PART is received from websocket when True.
- It will still remove from cache if part_channels is manually called. Defaults to True.
-
- Attributes
- ------------
- loop: :class:`asyncio.AbstractEventLoop`
- The event loop the Client uses.
+ -----------
+ client_id: str
+ The client ID of the application you registered on the Twitch Developer Portal.
+ client_secret: str
+ The client secret of the application you registered on the Twitch Developer Portal.
+ This must be associated with the same `client_id`.
+ bot_id: str | None
+ An optional `str` which should be the User ID associated with the Bot Account.
+
+ It is highly recommended setting this parameter as it will allow TwitchIO to use the bot's own tokens where
+ appropriate and needed.
+ redirect_uri: str | None
+ An optional `str` to set as the redirect uri for anything relating to Twitch OAuth. You most often do not need to set
+ this.
+ scopes: twitchio.Scopes | None
+ An optional :class:`~twitchio.Scopes` object to use as defaults when using anything related to Twitch OAuth.
+
+ Useful when you want to set default scopes for users to authenticate with.
+ session: aiohttp.ClientSession | None
+ An optional :class:`aiohttp.ClientSession` to use for all HTTP requests including any requests made with
+ :class:`~twitchio.Asset`'s.
+ adapter: twitchio.StarletteAdapter | twitchio.AiohttpAdapter | None
+ An optional :class:`StarletteAdapter` or :class:`twitchio.AiohttpAdapter` to use as the clients web server adapter.
+
+ The adapter is a built-in webserver used for OAuth and when needed for EventSub over Webhooks.
+
+ When this is not provided, it will default to a :class:`twitchio.AiohttpAdapter` with default settings.
+
+ When requiring an adapter for use with EventSub, you must provide an adapter with the correct settings set.
+ fetch_client_user: bool
+ An optional bool indicating whether to fetch and cache the client/bot accounts own :class:`.User` object to use with
+ :attr:`.user`.
+ Defaults to ``True``. You must pass ``bot_id`` for this parameter to have any effect.
"""
def __init__(
self,
- token: str,
*,
- client_secret: str = None,
- initial_channels: Union[list, tuple, Callable] = None,
- loop: asyncio.AbstractEventLoop = None,
- heartbeat: Optional[float] = 30.0,
- retain_cache: Optional[bool] = True,
- ):
- self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
- self._heartbeat = heartbeat
-
- token = token.replace("oauth:", "")
-
- self._http = TwitchHTTP(self, api_token=token, client_secret=client_secret)
- self._connection = WSConnection(
- client=self,
- token=token,
- loop=self.loop,
- initial_channels=initial_channels,
- heartbeat=heartbeat,
- retain_cache=retain_cache,
+ client_id: str,
+ client_secret: str,
+ bot_id: str | None = None,
+ **options: Unpack[ClientOptions],
+ ) -> None:
+ redirect_uri: str | None = options.get("redirect_uri")
+ scopes: Scopes | None = options.get("scopes")
+ session: aiohttp.ClientSession = options.get("session", MISSING) or MISSING
+ self._bot_id: str | None = bot_id
+
+ self._http = ManagedHTTPClient(
+ client_id=client_id,
+ client_secret=client_secret,
+ redirect_uri=redirect_uri,
+ scopes=scopes,
+ session=session,
)
+ adapter: BaseAdapter | type[BaseAdapter] = options.get("adapter", AiohttpAdapter)
+ if isinstance(adapter, BaseAdapter):
+ adapter.client = self
+ self._adapter = adapter
+ else:
+ self._adapter = adapter()
+ self._adapter.client = self
- self._events = {}
- self._waiting: List[Tuple[str, Callable[[...], bool], asyncio.Future]] = []
- self.registered_callbacks: Dict[Callable, str] = {}
- self._closing: Optional[asyncio.Event] = None
+ # Own Client User. Set in login...
+ self._fetch_self: bool = options.get("fetch_client_user", True)
+ self._user: User | PartialUser | None = None
- @classmethod
- def from_client_credentials(
- cls,
- client_id: str,
- client_secret: str,
- *,
- loop: asyncio.AbstractEventLoop = None,
- heartbeat: Optional[float] = 30.0,
- ) -> "Client":
+ self._listeners: dict[str, set[Callable[..., Coroutine[Any, Any, None]]]] = defaultdict(set)
+ self._wait_fors: dict[str, set[EventWaiter]] = defaultdict(set)
+
+ self._login_called: bool = False
+ self._has_closed: bool = False
+ self._save_tokens: bool = True
+
+ # Websockets for EventSub
+ self._websockets: dict[str, dict[str, Websocket]] = defaultdict(dict)
+
+ self._ready_event: asyncio.Event = asyncio.Event()
+ self._ready_event.clear()
+
+ self.__waiter: asyncio.Event = asyncio.Event()
+
+ @property
+ def tokens(self) -> MappingProxyType[str, TokenMappingData]:
+ """Property which returns a read-only mapping of the tokens that are managed by the `Client`.
+
+ **For various methods of managing the tokens on the client, see:**
+
+ :meth:`~.add_token`
+
+ :meth:`~.remove_token`
+
+ :meth:`~.load_tokens`
+
+ :meth:`~.save_tokens`
+
+
+ .. warning::
+
+ This method returns sensitive information such as user-tokens. You should take care not to expose these tokens.
"""
- creates a client application token from your client credentials.
+ return MappingProxyType(self._http._tokens)
- .. warning:
+ @property
+ def bot_id(self) -> str | None:
+ """Property which returns the User-ID associated with this :class:`~twitchio.Client` if set, or `None`.
- this is not suitable for logging in to IRC.
+ This can be set using the `bot_id` parameter when initialising the :class:`~twitchio.Client`.
- .. note:
+ .. note::
- This classmethod skips :meth:`~.__init__`
+ It is highly recommended to set this parameter.
+ """
+ return self._bot_id
- Parameters
- ------------
- client_id: :class:`str`
+ @property
+ def user(self) -> User | PartialUser | None:
+ """Property which returns the :class:`.User` or :class:`.PartialUser` associated with with the Client/Bot.
- client_secret: :class:`str`
- An application Client Secret used to generate Access Tokens automatically.
- loop: Optional[:class:`asyncio.AbstractEventLoop`]
- The event loop the client will use to run.
+ In most cases this will be a :class:`.User` object. Could be :class:`.PartialUser` when passing ``False`` to the
+ ``fetch_client_user`` keyword parameter of Client.
- Returns
- --------
- A new :class:`Client` instance
+ Could be ``None`` if no ``bot_id`` was passed to the Client constructor.
+
+ .. important::
+
+ If ``bot_id`` has not been passed to the constructor of :class:`.Client` this will return ``None``.
"""
- self = cls.__new__(cls)
- self.loop = loop or asyncio.get_event_loop()
- self._http = TwitchHTTP(self, client_id=client_id, client_secret=client_secret)
- self._heartbeat = heartbeat
- self._connection = WSConnection(
- client=self,
- loop=self.loop,
- initial_channels=None,
- heartbeat=self._heartbeat,
- ) # The only reason we're even creating this is to avoid attribute errors
- self._events = {}
- self._waiting = []
- self.registered_callbacks = {}
- return self
+ return self._user
- def run(self):
+ async def event_error(self, payload: EventErrorPayload) -> None:
"""
- A blocking function that starts the asyncio event loop,
- connects to the twitch IRC server, and cleans up when done.
+ Event called when an error occurs in an event or event listener.
+
+ This event can be overriden to handle event errors differently.
+ By default, this method logs the error and ignores it.
+
+ .. warning::
+
+ If an error occurs in this event, it will be ignored and logged. It will **NOT** re-trigger this event.
+
+ Parameters
+ ----------
+ payload: EventErrorPayload
+ A payload containing the Exception, the listener, and the original payload.
"""
+ logger.error('Ignoring Exception in listener "%s":\n', payload.listener.__qualname__, exc_info=payload.error)
+
+ async def _dispatch(self, listener: Callable[..., Coroutine[Any, Any, None]], *, original: Any | None = None) -> None:
try:
- task = self.loop.create_task(self.connect())
- self.loop.run_until_complete(task) # this'll raise if the connect fails
- self.loop.run_forever()
- except KeyboardInterrupt:
- pass
- finally:
- if not self._closing.is_set():
- self.loop.run_until_complete(self.close())
+ called_: Awaitable[None] = listener(original) if original else listener()
+ await called_
+ except Exception as e:
+ try:
+ payload: EventErrorPayload = EventErrorPayload(
+ error=e, listener=unwrap_function(listener), original=original
+ )
+ await self.event_error(payload)
+ except Exception as inner:
+ logger.error(
+ 'Ignoring Exception in listener "%s.event_error":\n', self.__class__.__qualname__, exc_info=inner
+ )
+
+ def dispatch(self, event: str, payload: Any | None = None) -> None:
+ name: str = "event_" + event.lower()
+
+ listeners: set[Callable[..., Coroutine[Any, Any, None]]] = self._listeners[name]
+ extra: Callable[..., Coroutine[Any, Any, None]] | None = getattr(self, name, None)
+ if extra:
+ listeners.add(extra)
+
+ logger.debug('Dispatching event: "%s" to %d listeners.', name, len(listeners))
+ _ = [asyncio.create_task(self._dispatch(listener, original=payload)) for listener in listeners]
+
+ waits: set[asyncio.Task[None]] = set()
+ for waiter in self._wait_fors[name]:
+ coro = waiter(payload) if payload else waiter()
+ task = asyncio.create_task(coro, name=f'TwitchIO:Client.wait_for: "{name}"')
+
+ task.add_done_callback(waits.discard)
+ waits.add(task)
+
+ async def setup_hook(self) -> None:
+ """
+ Method called after :meth:`~.login` has been called but before the client is ready.
+
+ :meth:`~.start` calls :meth:`~.login` internally for you, so when using
+ :meth:`~.start` this method will be called after the client has generated and validated an
+ app token. The client won't complete start up until this method has completed.
+
+ This method is intended to be overriden to provide an async environment for any setup required.
+
+ By default, this method does not implement any logic.
+ """
+ ...
+
+ async def login(self, *, token: str | None = None, load_tokens: bool = True, save_tokens: bool = True) -> None:
+ """Method to login the client and generate or store an app token.
+
+ This method is called automatically when using :meth:`~.start`.
+ You should **NOT** call this method if you are using :meth:`~.start`.
+
+ This method calls :meth:`~.setup_hook`.
+
+ .. note::
+
+ If no token is provided, the client will attempt to generate a new app token for you.
+ This is usually preferred as generating a token is inexpensive and does not have rate-limits associated with it.
+
+ Parameters
+ ----------
+ token: str | None
+ An optional app token to use instead of generating one automatically.
+ load_tokens: bool
+ Optional bool which indicates whether the :class:`Client` should call :meth:`.load_tokens` during
+ login automatically. Defaults to ``True``.
+ save_tokens: bool
+ Optional bool which inicates whether the :class:`Client` should call :meth:`.save_tokens` during the
+ :meth:`.close` automatically. Defaults to ``True``.
+ """
+ if self._login_called:
+ return
+
+ self._login_called = True
+ self._save_tokens = save_tokens
+
+ if not self._http.client_id:
+ raise RuntimeError('Expected a valid "client_id", instead received: %s', self._http.client_id)
+
+ if not token and not self._http.client_secret:
+ raise RuntimeError(f'Expected a valid "client_secret", instead received: {self._http.client_secret}')
+
+ if not token:
+ payload: ClientCredentialsPayload = await self._http.client_credentials_token()
+ validated: ValidateTokenPayload = await self._http.validate_token(payload.access_token)
+ token = payload.access_token
+
+ logger.info("Generated App Token for Client-ID: %s", validated.client_id)
+
+ self._http._app_token = token
+
+ if load_tokens:
+ async with self._http._token_lock:
+ await self.load_tokens()
+
+ if self._bot_id:
+ logger.debug("Fetching Clients self user for %r", self)
+ partial = PartialUser(id=self._bot_id, http=self._http)
+ self._user = partial if not self._fetch_self else await partial.user()
+
+ await self.setup_hook()
- self.loop.close()
+ async def __aenter__(self) -> Self:
+ return self
+
+ async def __aexit__(self, *_: Any) -> None:
+ await self.close()
- async def start(self):
+ async def start(
+ self,
+ token: str | None = None,
+ *,
+ with_adapter: bool = True,
+ load_tokens: bool = True,
+ save_tokens: bool = True,
+ ) -> None:
"""|coro|
- Connects to the twitch IRC server, and cleanly disconnects when done.
+ Method to login and run the `Client` asynchronously on an already running event loop.
+
+ You should not call :meth:`~.login` if you are using this method as it is called internally
+ for you.
+
+ .. note::
+
+ This method blocks asynchronously until the client is closed.
+
+ Parameters
+ ----------
+ token: str | None
+ An optional app token to use instead of generating one automatically.
+ with_adapter: bool
+ Whether to start and run a web adapter. Defaults to `True`. See: ... for more information.
+ load_tokens: bool
+ Optional bool which indicates whether the :class:`Client` should call :meth:`.load_tokens` during
+ :meth:`.login` automatically. Defaults to ``True``.
+ save_tokens: bool
+ Optional bool which inicates whether the :class:`Client` should call :meth:`.save_tokens` during the
+ :meth:`.close` automatically. Defaults to ``True``.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ import asyncio
+ import twitchio
+
+
+ async def main() -> None:
+ client = twitchio.Client(...)
+
+ async with client:
+ await client.start()
"""
- if self.loop is not asyncio.get_running_loop():
- raise RuntimeError(
- f"Attempted to start a {self.__class__.__name__} instance on a different loop "
- f"than the one it was initialized with."
- )
+ self.__waiter.clear()
+ await self.login(token=token, load_tokens=load_tokens, save_tokens=save_tokens)
+
+ if with_adapter:
+ await self._adapter.run()
+
+ # Dispatch ready event... May change places in the future.
+ self.dispatch("ready")
+ self._ready_event.set()
+
try:
- await self.connect()
- await self._closing.wait()
+ await self.__waiter.wait()
finally:
- if not self._closing.is_set():
- await self.close()
+ self._ready_event.clear()
+ await self.close()
- async def connect(self):
- """|coro|
+ def run(
+ self,
+ token: str | None = None,
+ *,
+ with_adapter: bool = True,
+ load_tokens: bool = True,
+ save_tokens: bool = True,
+ ) -> None:
+ """Method to login the client and create a continuously running event loop.
- Connects to the twitch IRC server
+ The behaviour of this method is similar to :meth:`~.start` but instead of being used in an already running
+ async environment, this method will setup and create an async environment for you.
+
+ You should not call :meth:`~.login` if you are using this method as it is called internally
+ for you.
+
+ .. important::
+
+ You can not use this method in an already running async event loop. See: :meth:`~.start` for starting the
+ client in already running async environments.
+
+ .. note::
+
+ This method will block until the client is closed.
+
+ Parameters
+ ----------
+ token: str | None
+ An optional app token to use instead of generating one automatically.
+ with_adapter: bool
+ Whether to start and run a web adapter. Defaults to `True`. See: ... for more information.
+ load_tokens: bool
+ Optional bool which indicates whether the :class:`Client` should call :meth:`.load_tokens` during
+ :meth:`.login` automatically. Defaults to ``True``.
+ save_tokens: bool
+ Optional bool which inicates whether the :class:`Client` should call :meth:`.save_tokens` during the
+ :meth:`.close` automatically. Defaults to ``True``.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ client = twitchio.Client(...)
+ client.run()
"""
- self._closing = asyncio.Event()
- await self._connection._connect()
- async def close(self):
- """|coro|
+ async def run() -> None:
+ async with self:
+ await self.start(token=token, with_adapter=with_adapter, load_tokens=load_tokens, save_tokens=save_tokens)
+
+ try:
+ asyncio.run(run())
+ except KeyboardInterrupt:
+ pass
+
+ async def close(self, **options: Any) -> None:
+ r"""Method which closes the :class:`~Client` gracefully.
+
+ This method is called for you automatically when using :meth:`~.run` or when using the client with the
+ async context-manager, E.g: `async with client:`
- Cleanly disconnects from the twitch IRC server
+ You can override this method to implement your own clean-up logic, however you should call `await super().close()`
+ when doing this.
+
+ Parameters
+ ----------
+ \*
+ save_tokens: bool | None
+ An optional bool override which allows overriding the identical keyword-argument set in either
+ :meth:`.run`, :meth:`.start` or :meth:`.login` to call the :meth:`.save_tokens` coroutine.
+ Defaults to ``None`` which won't override.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ async def close(self) -> None:
+ # Own clenup logic...
+ ...
+ await super().close()
"""
- self._closing.set()
- await self._connection._close()
+ if self._has_closed:
+ logger.debug("Client was already set as closed. Disregarding call to close.")
+ return
- def run_event(self, event_name, *args):
- name = f"event_{event_name}"
- logger.debug(f"dispatching event {event_name}")
+ self._has_closed = True
+ await self._http.close()
- async def wrapped(func):
+ if self._adapter._runner_task is not None:
try:
- await func(*args)
+ await self._adapter.close()
except Exception as e:
- if name == "event_error":
- # don't enter a dispatch loop!
- raise
-
- self.run_event("error", e)
-
- inner_cb = getattr(self, name, None)
- if inner_cb is not None:
- if inspect.iscoroutinefunction(inner_cb):
- self.loop.create_task(wrapped(inner_cb))
- else:
- warnings.warn(
- f"event '{name}' callback is not a coroutine",
- category=RuntimeWarning,
- )
+ logger.debug("Encountered a cleanup error while closing the Client Web Adapter: %s. Disregarding.", e)
+ pass
- if name in self._events:
- for event in self._events[name]:
- self.loop.create_task(wrapped(event))
+ sockets: list[Websocket] = [w for p in self._websockets.values() for w in p.values()]
+ logger.debug("Attempting cleanup on %d EventSub websocket connection(s).", len(sockets))
- for e, check, future in self._waiting:
- if e == event_name:
- if check(*args):
- future.set_result(args)
- if future.done():
- self._waiting.remove((e, check, future))
+ for socket in sockets:
+ await socket.close()
- def add_event(self, callback: Callable, name: str = None) -> None:
- try:
- func = callback.func
- except AttributeError:
- func = callback
+ save_tokens = options.get("save_tokens")
+ save = save_tokens if save_tokens is not None else self._save_tokens
- if not inspect.iscoroutine(func) and not inspect.iscoroutinefunction(func):
- raise ValueError("Event callback must be a coroutine")
+ if save:
+ async with self._http._token_lock:
+ await self.save_tokens()
- event_name = name or callback.__name__
- self.registered_callbacks[callback] = event_name
+ self._http.cleanup()
+ self.__waiter.set()
+ logger.debug("Cleanup completed on %r.", self)
- if event_name in self._events:
- self._events[event_name].append(callback)
+ async def wait_until_ready(self) -> None:
+ """|coro|
- else:
- self._events[event_name] = [callback]
+ Method which suspends the current coroutine and waits for "event_ready" to be dispatched.
- def remove_event(self, callback: Callable) -> bool:
- event_name = self.registered_callbacks.get(callback)
+ If "event_ready" has previously been dispatched, this method returns immediately.
- if event_name is None:
- raise ValueError("Event callback is not a registered event")
+ "event_ready" is dispatched after the HTTP Client has successfully logged in, tokens have sucessfully been loaded,
+ and :meth:`.setup_hook` has completed execution.
- if callback in self._events[event_name]:
- self._events[event_name].remove(callback)
- return True
+ .. warning::
- return False
+ Since this method directly relies on :meth:`.setup_hook` completing, using it in :meth:`.setup_hook` or in any
+ call :meth:`.setup_hook` is waiting for execution to complete, will completely deadlock the Client.
+ """
+ await self._ready_event.wait()
- def event(self, name: str = None) -> Callable:
- def decorator(func: Callable) -> Callable:
- self.add_event(func, name)
- return func
+ async def wait_for(self, event: str, *, timeout: float | None = None, predicate: WaitPredicateT | None = None) -> Any:
+ """Method which waits for any known dispatched event and returns the payload associated with the event.
- return decorator
+ This method can be used with a predicate check to determine whether the `wait_for` should stop listening and return
+ the event payload.
- async def wait_for(
- self,
- event: str,
- predicate: Callable[[], bool] = lambda *a: True,
- *,
- timeout=60.0,
- ) -> Tuple[Any]:
+ Parameters
+ ----------
+ event: str
+ The name of the event/listener to wait for. This should be the name of the event minus the `event_` prefix.
+
+ E.g. `chat_message`
+ timeout: float | None
+ An optional `float` to pass that this method will wait for a valid event. If `None` `wait_for` won't timeout.
+ Defaults to `None`.
+
+ If this method does timeout, the `TimeoutError` will be raised and propagated back.
+ predicate: WaitPredicateT
+ An optional `coroutine` to use as a check to determine whether this method should stop listening and return the
+ event payload. This coroutine should always return a bool.
+
+ The predicate function should take in the same payload as the event you are waiting for.
+
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ async def predicate(payload: twitchio.ChatMessage) -> bool:
+ # Only wait for a message sent by "chillymosh"
+ return payload.chatter.name == "chillymosh"
+
+ payload: twitchio.ChatMessage = await client.wait_for("chat_message", predicate=predicate)
+ print(f"Chillymosh said: {payload.text}")
+
+
+ Raises
+ ------
+ TimeoutError
+ Raised when waiting for an event that meets the requirements and passes the predicate check exceeds the timeout.
+
+ Returns
+ -------
+ Any
+ The payload associated with the event being listened to.
+ """
+ name: str = "event_" + event.lower()
+
+ set_ = self._wait_fors[name]
+ waiter: EventWaiter = EventWaiter(event=name, predicate=predicate, timeout=timeout)
+
+ waiter._set = set_
+ set_.add(waiter)
+
+ return await waiter.wait()
+
+ async def add_token(self, token: str, refresh: str) -> ValidateTokenPayload:
"""|coro|
+ Adds a token and refresh-token pair to the client to be automatically managed.
+
+ After successfully adding a token to the client, the token will be automatically revalidated and refreshed both when
+ required and periodically.
+
+ This method is automatically called in the :func:`~twitchio.events.event_oauth_authorized` event,
+ when a token is authorized by a user via the built-in OAuth adapter.
+
+ You can override the :func:`~twitchio.events.event_oauth_authorized` or this method to
+ implement custom functionality such as storing the token in a database.
- Waits for an event to be dispatched, then returns the events data
+ Storing your tokens safely is highly recommended and required to prevent users needing to reauthorize
+ your application after restarts.
+
+ .. note::
+
+ Both `token` and `refresh` are required parameters.
Parameters
- -----------
- event: :class:`str`
- The event to wait for. Do not include the `event_` prefix
- predicate: Callable[[...], bool]
- A check that is fired when the desired event is dispatched. if the check returns false,
- the waiting will continue until the timeout.
- timeout: :class:`int`
- How long to wait before timing out and raising an error.
+ ----------
+ token: str
+ The User-Access token to add.
+ refresh: str
+ The refresh token associated with the User-Access token to add.
- Returns
+ Examples
--------
- The arguments passed to the event.
+
+ .. code:: python3
+
+ class Client(twitchio.Client):
+
+ async def add_token(self, token: str, refresh: str) -> None:
+ # Code to add token to database here...
+ ...
+
+ # Adds the token to the client...
+ await super().add_token(token, refresh)
+
"""
- fut = self.loop.create_future()
- tup = (event, predicate, fut)
- self._waiting.append(tup)
- values = await asyncio.wait_for(fut, timeout)
- return values
+ return await self._http.add_token(token, refresh)
- def wait_for_ready(self) -> Coroutine[Any, Any, bool]:
+ async def remove_token(self, user_id: str, /) -> TokenMappingData | None:
"""|coro|
- Waits for the underlying connection to finish startup
+ Removes a token for the specified `user-ID` from the `Client`.
+
+ Removing a token will ensure the client stops managing the token.
+
+ This method has been made `async` for convenience when overriding the default functionality.
+
+ You can override this method to implement custom logic, such as removing a token from your database.
+
+ Parameters
+ ----------
+ user_id: str
+ The user-ID for the token to remove from the client. This argument is `positional-only`.
Returns
- --------
- :class:`bool` The state of the underlying flag. This will always be ``True``
+ -------
+ TokenMappingData
+ The token data assoicated with the user-id that was successfully removed.
+ None
+ The user-id was not managed by the client.
"""
- return self._connection.is_ready.wait()
+ return self._http.remove_token(user_id)
+
+ async def load_tokens(self, path: str | None = None, /) -> None:
+ """|coro|
+
+ Method used to load tokens when the :class:`~Client` starts.
+
+ .. note::
- @id_cache()
- def get_channel(self, name: str) -> Optional[Channel]:
- """Retrieve a channel from the cache.
+ This method is called by the client during :meth:`~.login` but **before**
+ :meth:`~.setup_hook` when the ``load_tokens`` keyword-argument
+ is ``True`` in either, :meth:`.run`, :meth:`.start` or :meth:`.login` (Default).
+
+ You can override this method to implement your own token loading logic into the client, such as from a database.
+
+ By default this method loads tokens from a file named `".tio.tokens.json"` if it is present;
+ always present if you use the default method of saving tokens.
+
+ **However**, it is preferred you would override this function to load your tokens from a database,
+ as this has far less chance of being corrupted, damaged or lost.
Parameters
- -----------
- name: str
- The channel name to retrieve from cache. Returns None if no channel was found.
+ ----------
+ path: str | None
+ The path to load tokens from, if this is `None` and the method has not been overriden, this will default to
+ `.tio.tokens.json`. Defaults to `None`.
- Returns
+ Examples
--------
- :class:`.Channel`
- """
- name = name.lower()
- if name in self._connection._cache:
- # Basically the cache doesn't store channels naturally, instead it stores a channel key
- # With the associated users as a set.
- # We create a Channel here and return it only if the cache has that channel key.
+ .. code:: python3
+
+ class Client(twitchio.Client):
- return Channel(name=name, websocket=self._connection)
+ async def load_tokens(self, path: str | None = None) -> None:
+ # Code to fetch all tokens from the database here...
+ ...
- async def part_channels(self, channels: Union[List[str], Tuple[str]]):
+ for row in tokens:
+ await self.add_token(row["token"], row["refresh"])
+
+ """
+ await self._http.load_tokens(name=path)
+
+ async def save_tokens(self, path: str | None = None, /) -> None:
"""|coro|
- Part the specified channels.
+ Method which saves all the added OAuth tokens currently managed by this Client.
+
+ .. note::
+
+ This method is called by the client when it is gracefully closed and the ``save_tokens`` keyword-argument
+ is ``True`` in either, :meth:`.run`, :meth:`.start` or :meth:`.login` (Default).
+
+ .. note::
+
+ By default this method saves to a JSON file named `".tio.tokens.json"`.
+
+ You can override this method to implement your own custom logic, such as saving tokens to a database, however
+ it is preferred to use :meth:`~.add_token` to ensure the tokens are handled as they are added.
Parameters
- ------------
- channels: Union[List[str], Tuple[str]]
- The channels in either a list or tuple form to part.
+ ----------
+ path: str | None
+ The path of the file to save to. Defaults to `.tio.tokens.json`.
"""
- await self._connection.part_channels(*channels)
+ await self._http.save(path)
- async def join_channels(self, channels: Union[List[str], Tuple[str]]):
- """|coro|
+ def add_listener(self, listener: Callable[..., Coroutine[Any, Any, None]], *, event: str | None = None) -> None:
+ """Method to add an event listener to the client.
- Join the specified channels.
+ See: :meth:`.listen` for more information on event listeners and for a decorator version of this function.
Parameters
- ------------
- channels: Union[List[str], Tuple[str]]
- The channels in either a list or tuple form to join.
+ ----------
+ listener: Callable[..., Coroutine[Any, Any, None]]
+ The coroutine to assign as the callback for the listener.
+ event: str | None
+ An optional :class:`str` which indicates which event to listen to. This should include the ``event_`` prefix.
+ Defaults to ``None`` which uses the coroutine function name passed instead.
+
+ Raises
+ ------
+ ValueError
+ The ``event`` string passed should start with ``event_``.
+ ValueError
+ The ``event`` string passed must not == ``event_``.
+ TypeError
+ The listener callback must be a coroutine function.
"""
- await self._connection.join_channels(*channels)
+ name: str = event or listener.__name__
- @property
- def connected_channels(self) -> List[Channel]:
- """A list of currently connected :class:`.Channel`"""
- return [self.get_channel(x) for x in self._connection._cache.keys()]
+ if not name.startswith("event_"):
+ raise ValueError('Listener and event names must start with "event_".')
- @property
- def events(self):
- """A mapping of events name to coroutine."""
- return self._events
+ if name == "event_":
+ raise ValueError('Listener and event names cannot be named "event_".')
- @property
- def nick(self):
- """The IRC bots nick."""
- return self._http.nick or self._connection.nick
+ if not asyncio.iscoroutinefunction(listener):
+ raise TypeError("Listeners and Events must be coroutines.")
- @property
- def user_id(self):
- """The IRC bot user id."""
- return self._http.user_id or self._connection.user_id
+ self._listeners[name].add(listener)
- def create_user(self, user_id: int, user_name: str) -> PartialUser:
- """
- A helper method to create a :class:`twitchio.PartialUser` from a user id and user name.
+ def remove_listener(
+ self,
+ listener: Callable[..., Coroutine[Any, Any, None]],
+ ) -> Callable[..., Coroutine[Any, Any, None]] | None:
+ """Method to remove a currently registered listener from the client.
Parameters
- -----------
- user_id: :class:`int`
- The id of the user
- user_name: :class:`str`
- The name of the user
+ ----------
+ listener: Callable[..., Coroutine[Any, Any, None]]
+ The coroutine wrapped with :meth:`.listen` or added via :meth:`.add_listener` to remove as a listener.
Returns
+ -------
+ Callable[..., Coroutine[Any, Any, None]]
+ If a listener was removed, the coroutine function will be returned.
+ None
+ Returns ``None`` when no listener was removed.
+ """
+ for listeners in self._listeners.values():
+ if listener in listeners:
+ listeners.remove(listener)
+ return listener
+
+ def listen(self, name: str | None = None) -> Any:
+ """|deco|
+
+ A decorator that adds a coroutine as an event listener.
+
+ Listeners listen for dispatched events on the :class:`.Client` or :class:`~.commands.Bot` and can come from multiple
+ sources, such as internally, or via EventSub. Unlike the overridable events built into bot
+ :class:`~Client` and :class:`~.commands.Bot`, listeners do not change the default functionality of the event,
+ and can be used as many times as required.
+
+ By default, listeners use the name of the function wrapped for the event name. This can be changed by passing the
+ name parameter.
+
+ For a list of events and their documentation, see: :ref:`Events Reference `.
+
+ For adding listeners to components, see: :meth:`~.commands.Component.listener`
+
+ Examples
--------
- :class:`twitchio.PartialUser`
+
+ .. code:: python3
+
+ @bot.listen()
+ async def event_message(message: twitchio.ChatMessage) -> None:
+ ...
+
+ # You can have multiple of the same event...
+ @bot.listen("event_message")
+ async def event_message_two(message: twitchio.ChatMessage) -> None:
+ ...
+
+ Parameters
+ ----------
+ name: str
+ The name of the event to listen to, E.g. ``"event_message"`` or simply ``"message"``.
"""
- return PartialUser(self._http, user_id, user_name)
- @user_cache()
- async def fetch_users(
- self,
- names: List[str] = None,
- ids: List[int] = None,
- token: str = None,
- force=False,
- ) -> List[User]:
- """|coro|
+ def wrapper(func: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., Coroutine[Any, Any, None]]:
+ name_ = name or func.__name__
+ qual = f"event_{name_.removeprefix('event_')}"
+
+ self.add_listener(func, event=qual)
+
+ return func
- Fetches users from the helix API
+ return wrapper
+
+ def create_partialuser(self, user_id: str | int, user_login: str | None = None) -> PartialUser:
+ """Helper method used to create :class:`twitchio.PartialUser` objects.
+
+ :class:`~twitchio.PartialUser`'s are used to make HTTP requests regarding users on Twitch.
+
+ .. versionadded:: 3.0.0
+
+ This has been renamed from `create_user` to `create_partialuser`.
Parameters
- -----------
- names: Optional[List[:class:`str`]]
- usernames of people to fetch
- ids: Optional[List[:class:`str`]]
- ids of people to fetch
- token: Optional[:class:`str`]
- An optional OAuth token to use instead of the bot OAuth token
- force: :class:`bool`
- whether to force a fetch from the api, or check the cache first. Defaults to False
+ ----------
+ user_id: str | int
+ ID of the user you wish to create a :class:`~twitchio.PartialUser` for.
+ user_login: str | None
+ Login name of the user you wish to create a :class:`~twitchio.PartialUser` for, if available.
Returns
- --------
- List[:class:`twitchio.User`]
+ -------
+ PartialUser
+ A :class:`~twitchio.PartialUser` object.
"""
- # the forced argument doesnt actually get used here, it gets used by the cache wrapper.
- # But we'll include it in the args here so that sphinx catches it
- assert names or ids
- data = await self._http.get_users(ids, names, token=token)
- return [User(self._http, x) for x in data]
+ return PartialUser(user_id, user_login, http=self._http)
- async def fetch_clips(self, ids: List[str]):
+ async def fetch_badges(self, *, token_for: str | PartialUser | None = None) -> list[ChatBadge]:
"""|coro|
- Fetches clips by clip id.
- To fetch clips by user id, use :meth:`twitchio.PartialUser.fetch_clips`
+ Fetches Twitch's list of global chat badges, which users may use in any channel's chat room.
Parameters
- -----------
- ids: List[:class:`str`]
- A list of clip ids
+ ----------
+ token_for: str | PartialUser | None
+ |token_for|
+
+ To fetch a specific broadcaster's chat badges, see: :meth:`~twitchio.PartialUser.fetch_badges`
Returns
--------
- List[:class:`twitchio.Clip`]
+ list[twitchio.ChatBadge]
+ A list of :class:`~twitchio.ChatBadge` objects
"""
- data = await self._http.get_clips(ids=ids)
- return [models.Clip(self._http, d) for d in data]
- async def fetch_channel(self, broadcaster: str, token: Optional[str] = None):
+ data = await self._http.get_global_chat_badges(token_for=token_for)
+ return [ChatBadge(x, http=self._http) for x in data["data"]]
+
+ async def fetch_emote_sets(
+ self, emote_set_ids: list[str], *, token_for: str | PartialUser | None = None
+ ) -> list[EmoteSet]:
"""|coro|
- Retrieve channel information from the API.
+ Fetches emotes for one or more specified emote sets.
.. note::
- This will be deprecated in 3.0. It's recommended to use :func:`~fetch_channels` instead.
+
+ An emote set groups emotes that have a similar context.
+ For example, Twitch places all the subscriber emotes that a broadcaster uploads for their channel
+ in the same emote set.
+
+ Parameters
+ ----------
+ emote_set_ids: list[str]
+ A list of the IDs that identifies the emote set to get. You may specify a maximum of **25** IDs.
+ token_for: str | PartialUser | None
+ |token_for|
+
+ Returns
+ -------
+ list[:class:`~twitchio.EmoteSet`]
+ A list of :class:`~twitchio.EmoteSet` objects.
+
+ Raises
+ ------
+ ValueError
+ You can only specify a maximum of **25** emote set IDs.
+ """
+
+ if len(emote_set_ids) > 25:
+ raise ValueError("You can only specify a maximum of 25 emote set IDs.")
+
+ data = await self._http.get_emote_sets(emote_set_ids=emote_set_ids, token_for=token_for)
+ template: str = data["template"]
+
+ return [EmoteSet(d, template=template, http=self._http) for d in data["data"]]
+
+ async def fetch_chatters_color(
+ self,
+ user_ids: list[str | int],
+ *,
+ token_for: str | PartialUser | None = None,
+ ) -> list[ChatterColor]:
+ """|coro|
+
+ Fetches the color of a chatter.
+
+ .. versionchanged:: 3.0
+
+ Removed the `token` parameter. Added the `token_for` parameter.
Parameters
-----------
- broadcaster: str
- The channel name or ID to request from API. Returns empty dict if no channel was found.
- token: Optional[:class:`str`]
- An optional OAuth token to use instead of the bot OAuth token.
+ user_ids: list[str | int]
+ A list of user ids to fetch the colours for.
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- :class:`twitchio.ChannelInfo`
+ list[:class:`~twitchio.ChatterColor`]
+ A list of :class:`~twitchio.ChatterColor` objects associated with the passed user IDs.
"""
+ if len(user_ids) > 100:
+ raise ValueError("Maximum of 100 user_ids")
- if not broadcaster.isdigit():
- get_id = await self.fetch_users(names=[broadcaster.lower()])
- if not get_id:
- raise IndexError("Invalid channel name.")
- broadcaster = str(get_id[0].id)
- try:
- data = await self._http.get_channels(broadcaster_id=broadcaster, token=token)
+ data = await self._http.get_user_chat_color(user_ids, token_for)
+ return [ChatterColor(d, http=self._http) for d in data["data"] if data]
+
+ async def fetch_channels(
+ self,
+ broadcaster_ids: list[str | int],
+ *,
+ token_for: str | PartialUser | None = None,
+ ) -> list[ChannelInfo]:
+ """|coro|
- from .models import ChannelInfo
+ Retrieve channel information from the API.
+
+ Parameters
+ ----------
+ broadcaster_ids: list[str | int]
+ A list of channel IDs to request from API.
+ You may specify a maximum of **100** IDs.
+ token_for: str | PartialUser | None
+ |token_for|
- return ChannelInfo(self._http, data=data[0])
+ Returns
+ --------
+ list[:class:`~twitchio.ChannelInfo`]
+ A list of :class:`~twitchio.ChannelInfo` objects.
+ """
+ if len(broadcaster_ids) > 100:
+ raise ValueError("Maximum of 100 broadcaster_ids")
- except HTTPException:
- raise HTTPException("Incorrect channel ID.")
+ data = await self._http.get_channel_info(broadcaster_ids, token_for)
+ return [ChannelInfo(d, http=self._http) for d in data["data"]]
- async def fetch_channels(self, broadcaster_ids: List[int], token: Optional[str] = None):
+ async def fetch_cheermotes(
+ self,
+ *,
+ broadcaster_id: int | str | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> list[Cheermote]:
"""|coro|
- Retrieve information for up to 100 channels from the API.
+ Fetches a list of Cheermotes that users can use to cheer Bits in any Bits-enabled channel's chat room.
+
+ Cheermotes are animated emotes that viewers can assign Bits to.
+ If a `broadcaster_id` is not specified then only global cheermotes will be returned.
+
+ If the broadcaster uploaded Cheermotes, the type attribute will be set to `channel_custom`.
Parameters
-----------
- broadcaster_ids: List[:class:`int`]
- The channel ids to request from API.
- token: Optional[:class:`str`]
- An optional OAuth token to use instead of the bot OAuth token
+ broadcaster_id: str | int | None
+ The ID of the broadcaster whose custom Cheermotes you want to fetch.
+ If not provided or `None` then you will fetch global Cheermotes. Defaults to `None`
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- List[:class:`twitchio.ChannelInfo`]
+ list[:class:`~twitchio.Cheermote`]
+ A list of :class:`~twitchio.Cheermote` objects.
"""
- from .models import ChannelInfo
+ data = await self._http.get_cheermotes(str(broadcaster_id) if broadcaster_id else None, token_for)
+ return [Cheermote(d, http=self._http) for d in data["data"]]
- data = await self._http.get_channels_new(broadcaster_ids=broadcaster_ids, token=token)
- return [ChannelInfo(self._http, data=d) for d in data]
-
- async def fetch_videos(
- self,
- ids: List[int] = None,
- game_id: int = None,
- user_id: int = None,
- period=None,
- sort=None,
- type=None,
- language=None,
- ):
+ async def fetch_classifications(
+ self, locale: str = "en-US", *, token_for: str | PartialUser | None = None
+ ) -> list[ContentClassificationLabel]:
+ # TODO: Docs need more info...
"""|coro|
- Fetches videos by id, game id, or user id
+ Fetches information about Twitch content classification labels.
Parameters
-----------
- ids: Optional[List[:class:`int`]]
- A list of video ids
- game_id: Optional[:class:`int`]
- A game to fetch videos from
- user_id: Optional[:class:`int`]
- A user to fetch videos from. See :meth:`twitchio.PartialUser.fetch_videos`
- period: Optional[:class:`str`]
- The period for which to fetch videos. Valid values are `all`, `day`, `week`, `month`. Defaults to `all`.
- Cannot be used when video id(s) are passed
- sort: :class:`str`
- Sort orders of the videos. Valid values are `time`, `trending`, `views`, Defaults to `time`.
- Cannot be used when video id(s) are passed
- type: Optional[:class:`str`]
- Type of the videos to fetch. Valid values are `upload`, `archive`, `highlight`. Defaults to `all`.
- Cannot be used when video id(s) are passed
- language: Optional[:class:`str`]
- Language of the videos to fetch. Must be an `ISO-639-1 `_ two letter code.
- Cannot be used when video id(s) are passed
+ locale: str
+ Locale for the Content Classification Labels.
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- List[:class:`twitchio.Video`]
+ list[:class:`~twitchio.ContentClassificationLabel`]
+ A list of :class:`~twitchio.ContentClassificationLabel` objects.
"""
- from .models import Video
+ data = await self._http.get_content_classification_labels(locale, token_for)
+ return [ContentClassificationLabel(d) for d in data["data"]]
- data = await self._http.get_videos(
- ids,
- user_id=user_id,
+ def fetch_clips(
+ self,
+ *,
+ game_id: str | None = None,
+ clip_ids: list[str] | None = None,
+ started_at: datetime.datetime | None = None,
+ ended_at: datetime.datetime | None = None,
+ featured: bool | None = None,
+ token_for: str | PartialUser | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[Clip]:
+ """|aiter|
+
+ Fetches clips by the provided clip ids or game id.
+
+ Parameters
+ -----------
+ game_id: list[str | int] | None
+ A game id to fetch clips from.
+ clip_ids: list[str] | None
+ A list of specific clip IDs to fetch.
+ The Maximum amount you can request is **100**.
+ started_at: datetime.datetime
+ The start date used to filter clips.
+ ended_at: datetime.datetime
+ The end date used to filter clips. If not specified, the time window is the start date plus one week.
+ featured: bool | None
+ When this parameter is `True`, this method returns only clips that are featured.
+ When this parameter is `False`, this method returns only clips that are not featured.
+
+ Othwerise if this parameter is not provided or `None`, all clips will be returned. Defaults to `None`.
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
+
+ Returns
+ --------
+ HTTPAsyncIterator[:class:`~twitchio.Clip`]
+
+ Raises
+ ------
+ ValueError
+ Only one of `game_id` or `clip_ids` can be provided.
+ ValueError
+ You must provide either a `game_id` *or* `clip_ids`.
+ """
+
+ provided: int = len([v for v in (game_id, clip_ids) if v])
+ if provided > 1:
+ raise ValueError("Only one of 'game_id' or 'clip_ids' can be provided.")
+ elif provided == 0:
+ raise ValueError("One of 'game_id' or 'clip_ids' must be provided.")
+
+ first = max(1, min(100, first))
+
+ return self._http.get_clips(
game_id=game_id,
- period=period,
- sort=sort,
- type=type,
- language=language,
+ clip_ids=clip_ids,
+ first=first,
+ started_at=started_at,
+ ended_at=ended_at,
+ is_featured=featured,
+ max_results=max_results,
+ token_for=token_for,
)
- return [Video(self._http, x) for x in data]
- async def fetch_cheermotes(self, user_id: int = None):
- """|coro|
+ def fetch_extension_transactions(
+ self,
+ extension_id: str,
+ *,
+ ids: list[str] | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[ExtensionTransaction]:
+ # TODO: Check docs?...
+ """|aiter|
+
+ Fetches global emotes from the Twitch API.
+ .. note::
- Fetches cheermotes from the twitch API
+ The ID in the `extension_id` parameter must match the Client-ID provided to this :class:`~Client`.
Parameters
-----------
- user_id: Optional[:class:`int`]
- The channel id to fetch from.
+ extension_id: str
+ The ID of the extension whose list of transactions you want to fetch.
+ ids: list[str] | None
+ A transaction ID used to filter the list of transactions.
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
Returns
--------
- List[:class:`twitchio.CheerEmote`]
+ HTTPAsyncIterator[:class:`~twitchio.ExtensionTransaction`]
"""
- data = await self._http.get_cheermotes(str(user_id) if user_id else None)
- return [models.CheerEmote(self._http, x) for x in data]
- async def fetch_global_emotes(self):
+ first = max(1, min(100, first))
+
+ if ids and len(ids) > 100:
+ raise ValueError("You can only provide a mximum of 100 IDs")
+
+ return self._http.get_extension_transactions(
+ extension_id=extension_id,
+ ids=ids,
+ first=first,
+ max_results=max_results,
+ )
+
+ async def fetch_extensions(self, *, token_for: str | PartialUser) -> list[Extension]:
"""|coro|
- Fetches global emotes from the twitch API
+ Fetch a list of all extensions (both active and inactive) that the broadcaster has installed.
- Returns
- --------
- List[:class:`twitchio.GlobalEmote`]
- """
- from .models import GlobalEmote
+ The user ID in the access token identifies the broadcaster.
- data = await self._http.get_global_emotes()
- return [GlobalEmote(self._http, x) for x in data]
+ .. note::
- async def fetch_top_games(self) -> List[models.Game]:
- """|coro|
+ Requires a user access token that includes the `user:read:broadcast` or `user:edit:broadcast` scope.
+ To include inactive extensions, you must include the `user:edit:broadcast` scope.
+
+ Parameters
+ ----------
+ token_for: str | PartialUser
+ The User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+ The token must inlcude the `user:read:broadcast` or `user:edit:broadcast` scope.
- Fetches the top games from the api
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ To include inactive extensions, you must include the `user:edit:broadcast` scope.
Returns
- --------
- List[:class:`twitchio.Game`]
+ -------
+ list[:class:`~twitchio.UserExtension`]
+ List of :class:`~twitchio.UserExtension` objects.
"""
- data = await self._http.get_top_games()
- return [models.Game(d) for d in data]
+ data = await self._http.get_user_extensions(token_for=token_for)
+ return [Extension(d) for d in data["data"]]
- async def fetch_games(
- self, ids: Optional[List[int]] = None, names: Optional[List[str]] = None, igdb_ids: Optional[List[int]] = None
- ) -> List[models.Game]:
+ async def update_extensions(
+ self, *, user_extensions: ActiveExtensions, token_for: str | PartialUser
+ ) -> ActiveExtensions:
"""|coro|
- Fetches games by id or name.
- At least one id or name must be provided
+ Update an installed extension's information for a specific broadcaster.
+
+ You can update the extension's activation `state`, `ID`, and `version number`.
+ The User-ID passed to `token_for` identifies the broadcaster whose extensions you are updating.
+
+ .. note::
+
+ The best way to change an installed extension's configuration is to use
+ :meth:`~twitchio.PartialUser.fetch_active_extensions` to fetch the extension.
+
+ You can then edit the approperiate extension within the `ActiveExtensions` model and pass it to this method.
+
+ .. note::
+
+ Requires a user access token that includes the `user:edit:broadcast` scope.
+ See: :meth:`~.add_token` to add managed tokens to the client.
Parameters
- -----------
- ids: Optional[List[:class:`int`]]
- An optional list of game ids
- names: Optional[List[:class:`str`]]
- An optional list of game names
- igdb_ids: Optional[List[:class:`int`]]
- An optional list of IGDB game ids
+ ----------
+ token_for: str | PartialUser
+ The User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+ The token must inlcude the `user:edit:broadcast` scope.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
Returns
- --------
- List[:class:`twitchio.Game`]
+ -------
+ ActiveExtensions
+ The :class:`~twitchio.ActiveExtensions` object.
"""
+ data = await self._http.put_user_extensions(user_extensions=user_extensions, token_for=token_for)
+ return ActiveExtensions(data["data"])
- data = await self._http.get_games(ids, names, igdb_ids)
- return [models.Game(d) for d in data]
-
- async def fetch_tags(self, ids: Optional[List[str]] = None):
+ async def fetch_emotes(self, *, token_for: str | PartialUser | None = None) -> list[GlobalEmote]:
"""|coro|
- Fetches stream tags.
+ Fetches global emotes from the Twitch API.
+
+ .. note::
+ If you wish to fetch a specific broadcaster's chat emotes use :meth:`~twitchio.PartialUser.fetch_channel_emotes`.
Parameters
- -----------
- ids: Optional[List[:class:`str`]]
- The ids of the tags to fetch
+ ----------
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- List[:class:`twitchio.Tag`]
+ list[:class:`twitchio.GlobalEmote`]
+ A list of :class:`~twitchio.GlobalEmote` objects.
"""
- data = await self._http.get_stream_tags(ids)
- return [models.Tag(x) for x in data]
+ data = await self._http.get_global_emotes(token_for)
+ template: str = data["template"]
- async def fetch_streams(
+ return [GlobalEmote(d, template=template, http=self._http) for d in data["data"]]
+
+ def fetch_streams(
self,
- user_ids: Optional[List[int]] = None,
- game_ids: Optional[List[int]] = None,
- user_logins: Optional[List[str]] = None,
- languages: Optional[List[str]] = None,
- token: Optional[str] = None,
+ *,
+ user_ids: list[int | str] | None = None,
+ game_ids: list[int | str] | None = None,
+ user_logins: list[int | str] | None = None,
+ languages: list[str] | None = None,
type: Literal["all", "live"] = "all",
- ):
- """|coro|
+ token_for: str | PartialUser | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[Stream]:
+ """|aiter|
- Fetches live streams from the helix API
+ Fetches streams from the Twitch API.
Parameters
-----------
- user_ids: Optional[List[:class:`int`]]
- user ids of people whose streams to fetch
- game_ids: Optional[List[:class:`int`]]
- game ids of streams to fetch
- user_logins: Optional[List[:class:`str`]]
- user login names of people whose streams to fetch
- languages: Optional[List[:class:`str`]]
- language for the stream(s). ISO 639-1 or two letter code for supported stream language
- token: Optional[:class:`str`]
- An optional OAuth token to use instead of the bot OAuth token
+ user_ids: list[int | str] | None
+ An optional list of User-IDs to fetch live stream information for.
+ game_ids: list[int | str] | None
+ An optional list of Game-IDs to fetch live streams for.
+ user_logins: list[str] | None
+ An optional list of User-Logins to fetch live stream information for.
+ languages: list[str] | None
+ A language code used to filter the list of streams. Returns only streams that broadcast in the specified language.
+ Specify the language using an ISO 639-1 two-letter language code or other if the broadcast uses a language not in the list of `supported stream languages `_.
+ You may specify a maximum of `100` language codes.
type: Literal["all", "live"]
- One of ``"all"`` or ``"live"``. Defaults to ``"all"``. Specifies what type of stream to fetch.
+ One of `"all"` or `"live"`. Defaults to `"all"`. Specifies what type of stream to fetch.
+
+ .. important::
+ Twitch deprecated filtering streams by type. `all` and `live` both return the same data.
+ This is being kept in the library in case of future additions.
+
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
Returns
--------
- List[:class:`twitchio.Stream`]
+ HTTPAsyncIterator[:class:`twitchio.Stream`]
"""
- from .models import Stream
- data = await self._http.get_streams(
+ first = max(1, min(100, first))
+
+ return self._http.get_streams(
+ first=first,
game_ids=game_ids,
user_ids=user_ids,
user_logins=user_logins,
languages=languages,
- type_=type,
- token=token,
+ type=type,
+ token_for=token_for,
+ max_results=max_results,
)
- return [Stream(self._http, x) for x in data]
- async def fetch_teams(
+ async def fetch_team(
self,
- team_name: Optional[str] = None,
- team_id: Optional[str] = None,
- ):
+ *,
+ team_name: str | None = None,
+ team_id: str | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> Team:
"""|coro|
- Fetches information for a specific Twitch Team.
+ Fetches information about a specific Twitch team.
+
+ You must provide one of either `team_name` or `team_id`.
Parameters
-----------
- name: Optional[:class:`str`]
- Team name to fetch
- id: Optional[:class:`str`]
- Team id to fetch
+ team_name: str | None
+ The team name.
+ team_id: str | None
+ The team id.
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- List[:class:`twitchio.Team`]
+ Team
+ The :class:`twitchio.Team` object.
+
+ Raises
+ ------
+ ValueError
+ You can only provide either `team_name` or `team_id`, not both.
"""
- from .models import Team
- assert team_name or team_id
+ if team_name and team_id:
+ raise ValueError("Only one of 'team_name' or 'team_id' should be provided, not both.")
+
data = await self._http.get_teams(
team_name=team_name,
team_id=team_id,
+ token_for=token_for,
)
- return Team(self._http, data[0])
+ return Team(data["data"][0], http=self._http)
- async def search_categories(self, query: str):
- """|coro|
+ def fetch_top_games(
+ self,
+ *,
+ token_for: str | PartialUser | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[Game]:
+ # TODO: Docs??? More info...
+ """|aiter|
- Searches twitches categories
+ Fetches information about the current top games on Twitch.
Parameters
-----------
- query: :class:`str`
- The query to search for
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
Returns
--------
- List[:class:`twitchio.Game`]
+ HTTPAsyncIterator[:class:`twitchio.Game`]
"""
- data = await self._http.get_search_categories(query)
- return [models.Game(x) for x in data]
- async def search_channels(self, query: str, *, live_only=False):
+ first = max(1, min(100, first))
+
+ return self._http.get_top_games(first=first, token_for=token_for, max_results=max_results)
+
+ async def fetch_games(
+ self,
+ *,
+ names: list[str] | None = None,
+ ids: list[str] | None = None,
+ igdb_ids: list[str] | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> list[Game]:
+ # TODO: Docs??? More info...
"""|coro|
- Searches channels for the given query
+ Fetches information about multiple games on Twitch.
Parameters
-----------
- query: :class:`str`
- The query to search for
- live_only: :class:`bool`
- Only search live channels. Defaults to False
+ names: list[str] | None
+ A list of game names to use to fetch information about. Defaults to `None`.
+ ids: list[str] | None
+ A list of game ids to use to fetch information about. Defaults to `None`.
+ igdb_ids: list[str] | None
+ A list of `igdb` ids to use to fetch information about. Defaults to `None`.
+ token_for: str | PartialUser | None
+ |token_for|
Returns
--------
- List[:class:`twitchio.SearchUser`]
+ list[:class:`twitchio.Game`]
+ A list of :class:`twitchio.Game` objects.
"""
- data = await self._http.get_search_channels(query, live=live_only)
- return [SearchUser(self._http, x) for x in data]
- async def delete_videos(self, token: str, ids: List[int]) -> List[int]:
+ data = await self._http.get_games(
+ names=names,
+ ids=ids,
+ igdb_ids=igdb_ids,
+ token_for=token_for,
+ )
+
+ return [Game(d, http=self._http) for d in data["data"]]
+
+ async def fetch_game(
+ self,
+ *,
+ name: str | None = None,
+ id: str | None = None,
+ igdb_id: str | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> Game | None:
"""|coro|
- Delete videos from the api. Returns the video ids that were successfully deleted.
+ Fetch a :class:`~twitchio.Game` object with the provided `name`, `id`, or `igdb_id`.
+
+ One of `name`, `id`, or `igdb_id` must be provided.
+ If more than one is provided or no parameters are provided, a `ValueError` will be raised.
+
+ If no game is found, `None` will be returned.
+
+ .. note::
+
+ See: :meth:`~.fetch_games` to fetch multiple games at once.
+
+ See: :meth:`~.fetch_top_games` to fetch the top games currently being streamed.
Parameters
- -----------
- token: :class:`str`
- An oauth token with the ``channel:manage:videos`` scope
- ids: List[:class:`int`]
- A list of video ids from the channel of the oauth token to delete
+ ----------
+ name: str | None
+ The name of the game to fetch.
+ id: str | None
+ The id of the game to fetch.
+ igdb_id: str | None
+ The igdb id of the game to fetch.
+ token_for: str | PartialUser | None
+ |token_for|
Returns
- --------
- List[:class:`int`]
+ -------
+ Game | None
+ The :class:`twitchio.Game` object if found, otherwise `None`.
+
+ Raises
+ ------
+ ValueError
+ Only one of the `name`, `id`, or `igdb_id` parameters can be provided.
+ ValueError
+ One of the `name`, `id`, or `igdb_id` parameters must be provided.
"""
- resp = []
- for chunk in [ids[x : x + 3] for x in range(0, len(ids), 3)]:
- resp.append(await self._http.delete_videos(token, chunk))
+ provided: int = len([v for v in (name, id, igdb_id) if v])
+ if provided > 1:
+ raise ValueError("Only one of 'name', 'id', or 'igdb_id' can be provided.")
+ elif provided == 0:
+ raise ValueError("One of 'name', 'id', or 'igdb_id' must be provided.")
- return resp
+ names: list[str] | None = [name] if name else None
+ id_: list[str] | None = [id] if id else None
+ igdb_ids: list[str] | None = [igdb_id] if igdb_id else None
- async def fetch_chatters_colors(self, user_ids: List[int], token: Optional[str] = None):
+ data = await self._http.get_games(names=names, ids=id_, igdb_ids=igdb_ids, token_for=token_for)
+ return Game(data["data"][0], http=self._http) if data["data"] else None
+
+ async def fetch_users(
+ self,
+ *,
+ ids: list[str | int] | None = None,
+ logins: list[str] | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> list[User]:
"""|coro|
- Fetches the color of a chatter.
+ Fetch information about one or more users.
+
+ .. note::
+
+ You may look up users using their user ID, login name, or both but the sum total
+ of the number of users you may look up is `100`.
+
+ For example, you may specify `50` IDs and `50` names or `100` IDs or names,
+ but you cannot specify `100` IDs and `100` names.
+
+ If you don't specify IDs or login names but provide the `token_for` parameter,
+ the request returns information about the user associated with the access token.
+
+ To include the user's verified email address in the response,
+ you must have a user access token that includes the `user:read:email` scope.
Parameters
- -----------
- user_ids: List[:class:`int`]
- List of user ids to fetch the colors for
- token: Optional[:class:`str`]
- An optional user oauth token
+ ----------
+ ids: list[str | int] | None
+ The ids of the users to fetch information about.
+ logins: list[str] | None
+ The login names of the users to fetch information about.
+ token_for: str | PartialUser | None
+ |token_for|
+
+ If this parameter is provided, the token must have the `user:read:email` scope
+ in order to request the user's verified email address.
Returns
- --------
- List[:class:`twitchio.ChatterColor`]
+ -------
+ list[:class:`twitchio.User`]
+ A list of :class:`twitchio.User` objects.
+
+ Raises
+ ------
+ ValueError
+ The combined number of 'ids' and 'logins' must not exceed `100` elements.
"""
- data = await self._http.get_user_chat_color(user_ids, token)
- return [models.ChatterColor(self._http, x) for x in data]
- async def update_chatter_color(self, token: str, user_id: int, color: str):
- """|coro|
+ if (len(ids or []) + len(logins or [])) > 100:
+ raise ValueError("The combined number of 'ids' and 'logins' must not exceed 100 elements.")
+
+ data = await self._http.get_users(ids=ids, logins=logins, token_for=token_for)
+ return [User(d, http=self._http) for d in data["data"]]
+
+ def search_categories(
+ self,
+ query: str,
+ *,
+ token_for: str | PartialUser | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[Game]:
+ """|aiter|
- Updates the color of the specified user in the specified channel/broadcaster's chat.
+ Searches Twitch categories via the API.
Parameters
-----------
- token: :class:`str`
- An oauth token with the ``user:manage:chat_color`` scope.
- user_id: :class:`int`
- The ID of the user whose color is being updated, this must match the user ID in the token.
- color: :class:`str`
- Turbo and Prime users may specify a named color or a Hex color code like #9146FF.
- Please see the Twitch documentation for more information.
+ query: str
+ The query to search for.
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
Returns
--------
- None
+ HTTPAsyncIterator[:class:`twitchio.Game`]
"""
- await self._http.put_user_chat_color(token=token, user_id=str(user_id), color=color)
- async def fetch_global_chat_badges(self):
- """|coro|
+ first = max(1, min(100, first))
- Fetches Twitch's list of chat badges, which users may use in any channel's chat room.
+ return self._http.get_search_categories(
+ query=query,
+ first=first,
+ max_results=max_results,
+ token_for=token_for,
+ )
- Returns
- --------
- List[:class:`twitchio.ChatBadge`]
- """
+ def search_channels(
+ self,
+ query: str,
+ *,
+ live: bool = False,
+ token_for: str | PartialUser | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[SearchChannel]:
+ """|aiter|
- data = await self._http.get_global_chat_badges()
- return [models.ChatBadge(x) for x in data]
+ Searches Twitch channels that match the specified query and have streamed content within the past `6` months.
- async def fetch_content_classification_labels(self, locale: Optional[str] = None):
- """|coro|
+ .. note::
- Fetches information about Twitch content classification labels.
+ If the `live` parameter is set to `False` (default), the query will look to match broadcaster login names.
+ If the `live` parameter is set to `True`, the query will match on the broadcaster login names and category names.
+
+ To match, the beginning of the broadcaster's name or category must match the query string.
+
+ The comparison is case insensitive. If the query string is `angel_of_death`,
+ it will matche all names that begin with `angel_of_death`.
+
+ However, if the query string is a phrase like `angel of death`, it will match
+ to names starting with `angelofdeath` *or* names starting with `angel_of_death`.
Parameters
-----------
- locale: Optional[:class:`str`]
- Locale for the Content Classification Labels.
- You may specify a maximum of 1 locale. Default: “en-US”
+ query: str
+ The query to search for.
+ live: bool
+ Whether to return live channels only.
+ Defaults to `False`.
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
Returns
--------
- List[:class:`twitchio.ContentClassificationLabel`]
+ HTTPAsyncIterator[:class:`twitchio.SearchChannel`]
"""
- locale = "en-US" if locale is None else locale
- data = await self._http.get_content_classification_labels(locale)
- return [models.ContentClassificationLabel(x) for x in data]
- async def get_webhook_subscriptions(self):
- """|coro|
+ first = max(1, min(100, first))
- Fetches your current webhook subscriptions. Requires your bot to be logged in with an app access token.
+ return self._http.get_search_channels(
+ query=query,
+ first=first,
+ live=live,
+ max_results=max_results,
+ token_for=token_for,
+ )
- Returns
- --------
- List[:class:`twitchio.WebhookSubscription`]
- """
- data = await self._http.get_webhook_subs()
- return [models.WebhookSubscription(x) for x in data]
+ def fetch_videos(
+ self,
+ *,
+ ids: list[str | int] | None = None,
+ user_id: str | int | PartialUser | None = None,
+ game_id: str | int | None = None,
+ language: str | None = None,
+ period: Literal["all", "day", "month", "week"] = "all",
+ sort: Literal["time", "trending", "views"] = "time",
+ type: Literal["all", "archive", "highlight", "upload"] = "all",
+ first: int = 20,
+ max_results: int | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> HTTPAsyncIterator[Video]:
+ """|aiter|
+
+ Fetch a list of :class:`~twitchio.Video` objects with the provided `ids`, `user_id` or `game_id`.
+
+ One of `ids`, `user_id` or `game_id` must be provided.
+ If more than one is provided or no parameters are provided, a `ValueError` will be raised.
- async def event_token_expired(self):
- """|coro|
+ Parameters
+ ----------
+ ids: list[str | int] | None
+ A list of video IDs to fetch.
+ user_id: str | int | PartialUser | None
+ The ID of the user whose list of videos you want to fetch.
+ game_id: str | int | None
+ The igdb id of the game to fetch.
+ language: str | None
+ A filter used to filter the list of videos by the language that the video owner broadcasts in.
+ For example, to get videos that were broadcast in German, set this parameter to the ISO 639-1 two-letter code for German (i.e., DE).
- A special event called when the oauth token expires. This is a hook into the http system, it will call this
- when a call to the api fails due to a token expiry. This function should return either a new token, or `None`.
- Returning `None` will cause the client to attempt an automatic token generation.
+ For a list of supported languages, see `Supported Stream Language `_. If the language is not supported, use `other`.
- .. note::
- This event is a callback hook. It is not a dispatched event. Any errors raised will be passed to the
- :func:`~event_error` event.
- """
- return None
+ .. note::
- async def event_mode(self, channel: Channel, user: User, status: str):
- """|coro|
+ Specify this parameter only if you specify the game_id query parameter.
+ period: Literal["all", "day", "month", "week"]
+ A filter used to filter the list of videos by when they were published. For example, videos published in the last week.
+ Possible values are: `all`, `day`, `month`, `week`.
- Event called when a MODE is received from Twitch.
+ The default is `all`, which returns videos published in all periods.
- Parameters
- ------------
- channel: :class:`.Channel`
- Channel object relevant to the MODE event.
- user: :class:`.User`
- User object containing relevant information to the MODE.
- status: str
- The JTV status received by Twitch. Could be either o+ or o-.
- Indicates a moderation promotion/demotion to the :class:`.User`
- """
- pass
+ .. note::
- async def event_userstate(self, user: User):
- """|coro|
+ Specify this parameter only if you specify the game_id or user_id query parameter.
+ sort: Literal["time", "trending", "views"]
+ The order to sort the returned videos in.
- Event called when a USERSTATE is received from Twitch.
+ +------------+---------------------------------------------------------------+
+ | Sort Key | Description |
+ +============+===============================================================+
+ | time | Sort the results in descending order by when they were |
+ | | created (i.e., latest video first). |
+ +------------+---------------------------------------------------------------+
+ | trending | Sort the results in descending order by biggest gains in |
+ | | viewership (i.e., highest trending video first). |
+ +------------+---------------------------------------------------------------+
+ | views | Sort the results in descending order by most views (i.e., |
+ | | highest number of views first). |
+ +------------+---------------------------------------------------------------+
- Parameters
- ------------
- user: :class:`.User`
- User object containing relevant information to the USERSTATE.
- """
- pass
+ The default is `time`.
- async def event_raw_usernotice(self, channel: Channel, tags: dict):
- """|coro|
+ .. note::
+ Specify this parameter only if you specify the game_id or user_id query parameter.
- Event called when a USERNOTICE is received from Twitch.
- Since USERNOTICE's can be fairly complex and vary, the following sub-events are available:
+ type: Literal["all", "archive", "highlight", "upload"]
+ A filter used to filter the list of videos by the video's type.
- :meth:`event_usernotice_subscription` :
- Called when a USERNOTICE Subscription or Re-subscription event is received.
+ +-----------+-------------------------------------------------------------+
+ | Type | Description |
+ +===========+=============================================================+
+ | all | Include all video types. |
+ +-----------+-------------------------------------------------------------+
+ | archive | On-demand videos (VODs) of past streams. |
+ +-----------+-------------------------------------------------------------+
+ | highlight | Highlight reels of past streams. |
+ +-----------+-------------------------------------------------------------+
+ | upload | External videos that the broadcaster uploaded using the |
+ | | Video Producer. |
+ +-----------+-------------------------------------------------------------+
- .. tip::
+ The default is `all`, which returns all video types.
- For more information on how to handle USERNOTICE's visit:
- https://dev.twitch.tv/docs/irc/tags/#usernotice-twitch-tags
+ .. note::
+ Specify this parameter only if you specify the game_id or user_id query parameter.
- Parameters
- ------------
- channel: :class:`.Channel`
- Channel object relevant to the USERNOTICE event.
- tags : dict
- A dictionary with the relevant information associated with the USERNOTICE.
- This could vary depending on the event.
+ token_for: str | PartialUser | None
+ |token_for|
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
+
+ Returns
+ -------
+ HTTPAsyncIterator[:class:`twitchio.Video`]
+
+ Raises
+ ------
+ ValueError
+ Only one of the 'ids', 'user_id', or 'game_id' parameters can be provided.
+ ValueError
+ One of the 'ids', 'user_id', or 'game_id' parameters must be provided.
"""
- pass
+ provided: int = len([v for v in (ids, game_id, user_id) if v])
+ if provided > 1:
+ raise ValueError("Only one of 'ids', 'user_id', or 'game_id' can be provided.")
+ elif provided == 0:
+ raise ValueError("One of 'name', 'id', or 'igdb_id' must be provided.")
+
+ first = max(1, min(100, first))
+
+ return self._http.get_videos(
+ ids=ids,
+ user_id=user_id,
+ game_id=game_id,
+ language=language,
+ period=period,
+ sort=sort,
+ type=type,
+ first=first,
+ max_results=max_results,
+ token_for=token_for,
+ )
- async def event_usernotice_subscription(self, metadata):
+ async def delete_videos(self, *, ids: list[str | int], token_for: str | PartialUser) -> list[str]:
"""|coro|
+ Deletes one or more videos for a specific broadcaster.
+
+ .. note::
+
+ You may delete past broadcasts, highlights, or uploads.
+
+ .. note::
+ This requires a user token with the scope `channel:manage:videos`.
+
+ The limit is to delete `5` ids at a time. When more than 5 ids are provided,
+ an attempt to delete them in chunks is made.
- Event called when a USERNOTICE subscription or re-subscription event is received from Twitch.
+ If any of the videos fail to delete in a chunked request, no videos will be deleted in that chunk.
Parameters
- ------------
- metadata: :class:`NoticeSubscription`
- The object containing various metadata about the subscription event.
- For ease of use, this contains a :class:`User` and :class:`Channel`.
+ ----------
+ ids: list[str | int] | None
+ A list of video IDs to delete.
+ token_for: str | PartialUser
+ The User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+ The token must inlcude the `channel:manage:videos` scope.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ Returns
+ -------
+ list[str]
+ A list of Video IDs that were successfully deleted.
"""
- pass
+ resp: list[str] = []
- async def event_part(self, user: User):
- """|coro|
+ for chunk in [ids[x : x + 5] for x in range(0, len(ids), 5)]:
+ data = await self._http.delete_videos(ids=chunk, token_for=token_for)
+ if data:
+ resp.extend(data["data"])
+
+ return resp
+ def fetch_stream_markers(
+ self,
+ *,
+ video_id: str,
+ token_for: str | PartialUser,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[VideoMarkers]:
+ """|aiter|
+
+ Fetches markers from a specific user's most recent stream or from the specified VOD/video.
+
+ A marker is an arbitrary point in a live stream that the broadcaster or editor has marked,
+ so they can return to that spot later to create video highlights.
+
+ .. important::
+
+ See: :meth:`~twitchio.PartialUser.fetch_stream_markers` for a more streamlined version of this method.
+
+ .. note::
- Event called when a PART is received from Twitch.
+ Requires a user access token that includes the `user:read:broadcast` *or* `channel:manage:broadcast` scope.
Parameters
- ------------
- user: :class:`.User`
- User object containing relevant information to the PART.
+ ----------
+ video_id: str
+ A video on demand (VOD)/video ID. The request returns the markers from this VOD/video.
+ The User ID provided to `token_for` must own the video or the user must be one of the broadcaster's editors.
+ token_for: str | PartialUser
+ The User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+ The token must inlcude the `user:read:broadcast` *or* `channel:manage:broadcast` scope
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
+
+ Returns
+ -------
+ HTTPAsyncIterator[:class:`twitchio.VideoMarkers`]
"""
- pass
+ first = max(1, min(100, first))
+ return self._http.get_stream_markers(
+ video_id=video_id,
+ token_for=token_for,
+ first=first,
+ max_results=max_results,
+ )
- async def event_join(self, channel: Channel, user: User):
- """|coro|
+ def fetch_drop_entitlements(
+ self,
+ *,
+ token_for: str | PartialUser | None = None,
+ ids: list[str] | None = None,
+ user_id: str | int | PartialUser | None = None,
+ game_id: str | None = None,
+ fulfillment_status: Literal["CLAIMED", "FULFILLED"] | None = None,
+ first: int = 20,
+ max_results: int | None = None,
+ ) -> HTTPAsyncIterator[Entitlement]:
+ # TODO: Docs??? More info in parameters?
+ """|aiter|
+
+ Fetches an organization's list of entitlements that have been granted to a `game`, a `user`, or `both`.
+
+ .. note::
+
+ Entitlements returned in the response body data are not guaranteed to be sorted by any field returned by the API.
+ To retrieve `CLAIMED` or `FULFILLED` entitlements, use the `fulfillment_status` query parameter to filter results.
+ To retrieve entitlements for a specific game, use the `game_id` query parameter to filter results.
+
+ .. note::
- Event called when a JOIN is received from Twitch.
+ Requires an app access token or user access token. The Client-ID associated with the token must own the game.
+
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | Access token type | Parameter | Description |
+ +====================+==================+======================================================================================================================================================================+
+ | App | None | If you don't specify request parameters, the request returns all entitlements that your organization owns. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | App | user_id | The request returns all entitlements for any game that the organization granted to the specified user. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | App | user_id, game_id | The request returns all entitlements that the specified game granted to the specified user. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | App | game_id | The request returns all entitlements that the specified game granted to all entitled users. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | User | None | If you don't specify request parameters, the request returns all entitlements for any game that the organization granted to the user identified in the access token. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | User | user_id | Invalid. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | User | user_id, game_id | Invalid. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | User | game_id | The request returns all entitlements that the specified game granted to the user identified in the access token. |
+ +--------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Parameters
- ------------
- channel: :class:`.Channel`
- The channel associated with the JOIN.
- user: :class:`.User`
- User object containing relevant information to the JOIN.
+ ----------
+ token_for: str | PartialUser | None
+ An optional User-ID that will be used to find an appropriate managed user token for this request.
+ The Client-ID associated with the token must own the game.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ If this paramter is not provided or `None`, the default app token is used.
+ ids: list[str] | None
+ A list of entitlement ids that identifies the entitlements to fetch.
+ user_id: str | int | PartialUser | None
+ An optional User ID of the user that was granted entitlements.
+ game_id: str | None
+ An ID that identifies a game that offered entitlements.
+ fulfillment_status: Literal["CLAIMED", "FULFILLED"] | None
+ The entitlement's fulfillment status. Used to filter the list to only those with the specified status.
+ Possible values are: `CLAIMED` and `FULFILLED`.
+ first: int
+ The maximum number of items to return per page. Defaults to **20**.
+ The maximum number of items per page is **100**.
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to `None`, all results are returned.
+ Defaults to `None`.
+
+ Returns
+ -------
+ HTTPAsyncIterator[:class:`twitchio.Entitlement`]
+
+ Raises
+ ------
+ ValueError
+ You may only specifiy a maximum of `100` ids.
"""
- pass
+ first = max(1, min(1000, first))
- async def event_message(self, message: Message):
+ if ids is not None and len(ids) > 100:
+ raise ValueError("You may specifiy a maximum of 100 ids.")
+
+ return self._http.get_drop_entitlements(
+ token_for=token_for,
+ ids=ids,
+ user_id=user_id,
+ game_id=game_id,
+ fulfillment_status=fulfillment_status,
+ max_results=max_results,
+ )
+
+ async def update_entitlements(
+ self,
+ *,
+ ids: list[str] | None = None,
+ fulfillment_status: Literal["CLAIMED", "FULFILLED"] | None = None,
+ token_for: str | PartialUser | None = None,
+ ) -> list[EntitlementStatus]:
"""|coro|
+ Updates a Drop entitlement's fulfillment status.
+
+ .. note::
+
+ Requires an app access token or user access token.
+ The Client-ID associated with the token must own the game associated with this drop entitlment.
- Event called when a PRIVMSG is received from Twitch.
+ +--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | Access token type | Updated Data |
+ +====================+=========================================================================================================================================================+
+ | App | Updates all entitlements with benefits owned by the organization in the access token. |
+ +--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+
+ | User | Updates all entitlements owned by the user in the access win the access token and where the benefits are owned by the organization in the access token. |
+ +--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+
Parameters
- ------------
- message: :class:`.Message`
- Message object containing relevant information.
+ ----------
+ ids: list[str] | None
+ A list of IDs that identify the entitlements to update. You may specify a maximum of **100** IDs.
+ fulfillment_status: Literal[""CLAIMED", "FULFILLED"] | None
+ The fulfillment status to set the entitlements to.
+ Possible values are: `CLAIMED` and `FULFILLED`.
+ token_for: str | PartialUser | None
+ An optional User ID that will be used to find an appropriate managed user token for this request.
+ The Client-ID associated with the token must own the game associated with this drop entitlment.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ If this paramter is not provided or `None`, the default app token is used.
+
+ Returns
+ -------
+ list[:class:`twitchio.EntitlementStatus`]
+ A list of :class:`twitchio.EntitlementStatus` objects.
+
+ Raises
+ ------
+ ValueError
+ You may only specifiy a maximum of **100** ids.
"""
- pass
+ if ids is not None and len(ids) > 100:
+ raise ValueError("You may specifiy a maximum of 100 ids.")
+
+ from .models.entitlements import EntitlementStatus
+
+ data = await self._http.patch_drop_entitlements(ids=ids, fulfillment_status=fulfillment_status, token_for=token_for)
+ return [EntitlementStatus(d) for d in data["data"]]
+
+ async def _subscribe(
+ self,
+ method: TransportMethod,
+ payload: SubscriptionPayload,
+ as_bot: bool = False,
+ token_for: str | None = None,
+ socket_id: str | None = None,
+ callback_url: str | None = None,
+ eventsub_secret: str | None = None,
+ ) -> SubscriptionResponse | None:
+ if method is TransportMethod.WEBSOCKET:
+ return await self.subscribe_websocket(payload=payload, as_bot=as_bot, token_for=token_for, socket_id=socket_id)
+
+ elif method is TransportMethod.WEBHOOK:
+ return await self.subscribe_webhook(
+ payload=payload,
+ as_bot=as_bot,
+ token_for=token_for,
+ callback_url=callback_url,
+ eventsub_secret=eventsub_secret,
+ )
- async def event_error(self, error: Exception, data: str = None):
+ async def subscribe_websocket(
+ self,
+ payload: SubscriptionPayload,
+ *,
+ as_bot: bool = False,
+ token_for: str | PartialUser | None = None,
+ socket_id: str | None = None,
+ ) -> SubscriptionResponse | None:
+ # TODO: Complete docs...
"""|coro|
+ Subscribe to an EventSub Event via Websockets.
- Event called when an error occurs while processing data.
+ .. note::
+
+ See: ... for more information and recipes on using eventsub.
Parameters
- ------------
- error: Exception
- The exception raised.
- data: str
- The raw data received from Twitch. Depending on how this is called, this could be None.
-
- Example
- ---------
- .. code:: py
-
- @bot.event()
- async def event_error(error, data):
- traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+ ----------
+ payload: :class:`twitchio.SubscriptionPayload`
+ The payload which should include the required conditions to subscribe to.
+ as_bot: bool
+ Whether to subscribe to this event using the user token associated with the provided
+ :attr:`Client.bot_id`. If this is set to `True` and `bot_id` has not been set, this method will
+ raise `ValueError`. Defaults to `False` on :class:`Client` but will default to `True` on
+ :class:`~twitchio.ext.commands.Bot`
+ token_for: str | PartialUser | None
+ An optional User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+
+ If `as_bot` is `True`, this is always the token associated with the
+ :attr:`~.bot_id` account. Defaults to `None`.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ If this paramter is not provided or `None`, the default app token is used.
+ socket_id: str | None
+ An optional `str` corresponding to an exisiting and connected websocket session, to use for this subscription.
+ You usually do not need to pass this parameter as TwitchIO delegates subscriptions to websockets as needed.
+ Defaults to `None`.
+
+ Returns
+ -------
+ SubscriptionResponse
+
+ Raises
+ ------
+ ValueError
+ One of the provided parameters is incorrect or incompatible.
+ HTTPException
+ An error was raised while making the subscription request to Twitch.
"""
- traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+ if as_bot and not self.bot_id:
+ raise ValueError("Client is missing 'bot_id'. Provide a 'bot_id' in the Client constructor.")
- async def event_ready(self):
- """|coro|
+ elif as_bot:
+ token_for = self.bot_id
+ if not token_for:
+ raise ValueError("A valid User Access Token must be passed to subscribe to eventsub over websocket.")
- Event called when the Bot has logged in and is ready.
+ if isinstance(token_for, PartialUser):
+ token_for = token_for.id
- Example
- ---------
- .. code:: py
+ sockets: dict[str, Websocket] = self._websockets[token_for]
+ websocket: Websocket
- @bot.event()
- async def event_ready():
- print(f'Logged into Twitch | {bot.nick}')
- """
- pass
+ if socket_id:
+ try:
+ websocket = sockets[socket_id]
+ except KeyError:
+ raise KeyError(f"The websocket with ID '{socket_id}' does not exist.")
- async def event_reconnect(self):
- """|coro|
+ elif not sockets:
+ websocket = Websocket(client=self, token_for=token_for, http=self._http)
+ await websocket.connect(fail_once=True)
- Event called when twitch sends a RECONNECT notice.
- The library will automatically handle reconnecting when such an event is received
- """
+ # session_id is guaranteed at this point.
+ self._websockets[token_for] = {websocket.session_id: websocket} # type: ignore
- async def event_raw_data(self, data: str):
- """|coro|
+ else:
+ sorted_: list[Websocket] = sorted(sockets.values(), key=lambda s: s.subscription_count)
+ try:
+ websocket = next(s for s in sorted_ if s.can_subscribe)
+ except StopIteration:
+ raise ValueError(
+ "No suitable websocket can be used to subscribe to this event. "
+ "You may have exahusted your 'toal_cost' allocation or max subscription count for this user token."
+ )
- Event called with the raw data received by Twitch.
+ session_id: str | None = websocket.session_id
+ if not session_id:
+ # This really shouldn't ever happen that I am aware of.
+ raise ValueError("Eventsub Websocket is missing 'session_id'.")
- Parameters
- ------------
- data: str
- The raw data received from Twitch.
+ type_ = SubscriptionType(payload.type)
+ version: str = payload.version
+ transport: SubscriptionCreateTransport = {"method": "websocket", "session_id": session_id}
- Example
- ---------
- .. code:: py
+ data: _SubscriptionData = {
+ "type": type_,
+ "version": version,
+ "condition": payload.condition,
+ "transport": transport,
+ "token_for": token_for,
+ }
- @bot.event()
- async def event_raw_data(data):
- print(data)
- """
- pass
+ try:
+ resp: SubscriptionResponse = await self._http.create_eventsub_subscription(**data)
+ except HTTPException as e:
+ if e.status == 409:
+ logger.error(
+ "Disregarding HTTPException in subscribe: "
+ "A subscription already exists for the specified event type and condition combination: '%s' and '%s'",
+ payload.type,
+ str(payload.condition),
+ )
+ return
- async def event_channel_joined(self, channel: Channel):
- """|coro|
+ raise e
- Event called when the bot joins a channel.
+ for sub in resp["data"]:
+ identifier: str = sub["id"]
+ websocket._subscriptions[identifier] = data
- Parameters
- ----------
- channel: :class:`.Channel`
- The channel that was joined.
- """
- pass
+ return resp
- async def event_channel_join_failure(self, channel: str):
+ async def subscribe_webhook(
+ self,
+ payload: SubscriptionPayload,
+ *,
+ as_bot: bool = False,
+ token_for: str | PartialUser | None,
+ callback_url: str | None = None,
+ eventsub_secret: str | None = None,
+ ) -> SubscriptionResponse | None:
+ # TODO: Complete docs...
"""|coro|
- Event called when the bot fails to join a channel.
+ Subscribe to an EventSub Event via Webhook.
+
+ .. note::
+
+ For more information on how to setup your bot with webhooks, see: ...
+
+ .. important::
+
+ Usually you wouldn't use webhooks to subscribe to the
+ :class:`~twitchio.eventsub.ChatMessageSubscription` subscription.
+
+ Consider using :meth:`~.subscribe_websocket` for this subscription.
Parameters
----------
- channel: `str`
- The channel name that was attempted to be joined.
+ payload: :class:`~twitchio.SubscriptionPayload`
+ The payload which should include the required conditions to subscribe to.
+ as_bot: bool
+ Whether to subscribe to this event using the user token associated with the provided
+ :attr:`Client.bot_id`. If this is set to `True` and `bot_id` has not been set, this method will
+ raise `ValueError`. Defaults to `False` on :class:`Client` but will default to `True` on
+ :class:`~twitchio.ext.commands.Bot`
+ token_for: str | PartialUser | None
+ An optional User ID, or PartialUser, that will be used to find an appropriate managed user token for this request.
+
+ If `as_bot` is `True`, this is always the token associated with the
+ :attr:`~.bot_id` account. Defaults to `None`.
+
+ See: :meth:`~.add_token` to add managed tokens to the client.
+ If this paramter is not provided or `None`, the default app token is used.
+ callback_url: str | None
+ An optional url to use as the webhook `callback_url` for this subscription. If you are using one of the built-in
+ web adapters, you should not need to set this. See: (web adapter docs link) for more info.
+ eventsub_secret: str | None
+ An optional `str` to use as the eventsub_secret, which is required by Twitch. If you are using one of the
+ built-in web adapters, you should not need to set this. See: (web adapter docs link) for more info.
+
+ Returns
+ -------
+ SubscriptionResponse
+
+ Raises
+ ------
+ ValueError
+ One of the provided parameters is incorrect or incompatible.
+ HTTPException
+ An error was raised while making the subscription request to Twitch.
"""
- logger.error(f'The channel "{channel}" was unable to be joined. Check the channel is valid.')
+ if as_bot and not self.bot_id:
+ raise ValueError("Client is missing 'bot_id'. Provide a 'bot_id' in the Client constructor.")
+
+ elif as_bot:
+ token_for = self.bot_id
+
+ if not token_for:
+ raise ValueError("A valid User Access Token must be passed to subscribe to eventsub over websocket.")
+
+ if not self._adapter and not callback_url:
+ raise ValueError(
+ "Either a 'twitchio.web' Adapter or 'callback_url' should be provided for webhook based eventsub."
+ )
+
+ callback: str | None = self._adapter.eventsub_url or callback_url
+ if not callback:
+ raise ValueError(
+ "A callback URL must be provided when subscribing to events via Webhook. "
+ "Use 'twitchio.web' Adapter or provide a 'callback_url'."
+ )
+
+ secret: str | None = self._adapter._eventsub_secret or eventsub_secret
+ if not secret:
+ raise ValueError("An eventsub secret must be provided when subscribing to events via Webhook. ")
+
+ if not 10 <= len(secret) <= 100:
+ raise ValueError("The 'eventsub_secret' must be between 10 and 100 characters long.")
+
+ if isinstance(token_for, PartialUser):
+ token_for = token_for.id
+
+ type_ = SubscriptionType(payload.type)
+ version: str = payload.version
+ transport: SubscriptionCreateTransport = {"method": "webhook", "callback": callback, "secret": secret}
+
+ data: _SubscriptionData = {
+ "type": type_,
+ "version": version,
+ "condition": payload.condition,
+ "transport": transport,
+ "token_for": token_for,
+ }
- async def event_raw_notice(self, data: str):
+ try:
+ resp: SubscriptionResponse = await self._http.create_eventsub_subscription(**data)
+ except HTTPException as e:
+ if e.status == 409:
+ logger.warning(
+ "Disregarding HTTPException in subscribe: "
+ "A subscription already exists for the specified event type and condition combination: '%s' and '%s'",
+ payload.type,
+ str(payload.condition),
+ )
+ return
+
+ raise e
+ return resp
+
+ async def fetch_eventsub_subscriptions(
+ self,
+ *,
+ token_for: str | PartialUser | None = None,
+ type: str | None = None,
+ user_id: str | PartialUser | None = None,
+ status: Literal[
+ "enabled",
+ "webhook_callback_verification_pending",
+ "webhook_callback_verification_failed",
+ "notification_failures_exceeded",
+ "authorization_revoked",
+ "moderator_removed",
+ "user_removed",
+ "version_removed",
+ "beta_maintenance",
+ "websocket_disconnected",
+ "websocket_failed_ping_pong",
+ "websocket_received_inbound_traffic",
+ "websocket_connection_unused",
+ "websocket_internal_error",
+ "websocket_network_timeout",
+ "websocket_network_error",
+ ]
+ | None = None,
+ max_results: int | None = None,
+ ) -> EventsubSubscriptions:
"""|coro|
+ Fetches Eventsub Subscriptions for either webhook or websocket.
- Event called with the raw NOTICE data received by Twitch.
+ .. note::
+ type, status and user_id are mutually exclusive and only one can be passed, otherwise ValueError will be raised.
Parameters
- ------------
- data: str
- The raw NOTICE data received from Twitch.
+ -----------
+ token_for: str | PartialUser | None
+ By default, if this is ignored or set to None then the App Token is used. This is the case when you want to fetch webhook events.
+
+ Provide a user ID here for when you want to fetch websocket events tied to a user.
+ type: str | None
+ Filter subscriptions by subscription type. e.g. ``channel.follow`` For a list of subscription types, see `Subscription Types `_.
+ user_id: str | PartialUser | None
+ Filter subscriptions by user ID, or PartialUser. The response contains subscriptions where this ID matches a user ID that you specified in the Condition object when you created the subscription.
+ status: str | None = None
+ Filter subscriptions by its status. Possible values are:
+
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | Status | Description |
+ +========================================+===================================================================================================================+
+ | enabled | The subscription is enabled. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | webhook_callback_verification_pending | The subscription is pending verification of the specified callback URL. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | webhook_callback_verification_failed | The specified callback URL failed verification. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | notification_failures_exceeded | The notification delivery failure rate was too high. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | authorization_revoked | The authorization was revoked for one or more users specified in the Condition object. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | moderator_removed | The moderator that authorized the subscription is no longer one of the broadcaster's moderators. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | user_removed | One of the users specified in the Condition object was removed. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | chat_user_banned | The user specified in the Condition object was banned from the broadcaster's chat. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | version_removed | The subscription to subscription type and version is no longer supported. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | beta_maintenance | The subscription to the beta subscription type was removed due to maintenance. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_disconnected | The client closed the connection. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_failed_ping_pong | The client failed to respond to a ping message. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_received_inbound_traffic | The client sent a non-pong message. Clients may only send pong messages (and only in response to a ping message). |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_connection_unused | The client failed to subscribe to events within the required time. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_internal_error | The Twitch WebSocket server experienced an unexpected error. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_network_timeout | The Twitch WebSocket server timed out writing the message to the client. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_network_error | The Twitch WebSocket server experienced a network error writing the message to the client. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+ | websocket_failed_to_reconnect | The client failed to reconnect to the Twitch WebSocket server within the required time after a Reconnect Message. |
+ +----------------------------------------+-------------------------------------------------------------------------------------------------------------------+
+
+ max_results: int | None
+ The maximum number of total results to return. When this parameter is set to ``None``, all results are returned.
+ Defaults to ``None``.
- Example
- ---------
- .. code:: py
+ Returns
+ --------
+ EventsubSubscriptions
- @bot.event()
- async def event_raw_notice(data):
- print(data)
+ Raises
+ ------
+ ValueError
+ Only one of 'status', 'user_id', or 'type' can be provided.
"""
- pass
- async def event_notice(self, message: str, msg_id: Optional[str], channel: Optional[Channel]):
+ provided: int = len([v for v in (type, user_id, status) if v])
+ if provided > 1:
+ raise ValueError("Only one of 'status', 'user_id', or 'type' can be provided.")
+
+ return await self._http.get_eventsub_subscription(
+ type=type,
+ max_results=max_results,
+ token_for=token_for,
+ )
+
+ async def delete_eventsub_subscription(self, id: str, *, token_for: str | PartialUser | None = None) -> None:
"""|coro|
+ Delete an eventsub subscription.
+
+ Parameters
+ ----------
+ id: str
+ The ID of the eventsub subscription to delete.
+ token_for: str | PartialUser | None
+ Do not pass this if you wish to delete webhook subscriptions, which are what usually require deleting.
- Event called with the NOTICE data received by Twitch.
+ For websocket subscriptions, provide the user ID, or PartialUser, associated with the subscription.
+
+ """
+ await self._http.delete_eventsub_subscription(id, token_for=token_for)
- .. tip::
+ async def delete_all_eventsub_subscriptions(self, *, token_for: str | PartialUser | None = None) -> None:
+ """|coro|
- For more information on NOTICE msg_ids visit:
- https://dev.twitch.tv/docs/irc/msg-id/
+ Delete all eventsub subscriptions.
Parameters
- ------------
- message: :class:`str`
- The message of the NOTICE.
- msg_id: Optional[:class:`str`]
- The msg_id that indicates what the NOTICE type.
- channel: Optional[:class:`~twitchio.Channel`]
- The channel the NOTICE message originated from.
-
- Example
- ---------
- .. code:: py
-
- @bot.event()
- async def event_notice(message, msg_id, channel):
- print(message)
+ ----------
+ token_for: str | PartialUser | None
+ Do not pass this if you wish to delete webhook subscriptions, which are what usually require deleting.
+
+ For websocket subscriptions, provide the user ID, or PartialUser, associated with the subscription.
"""
- pass
+ events = await self.fetch_eventsub_subscriptions(token_for=token_for)
+ async for sub in events.subscriptions:
+ await sub.delete()
+
+ async def event_oauth_authorized(self, payload: UserTokenPayload) -> None:
+ await self.add_token(payload["access_token"], payload["refresh_token"])
diff --git a/twitchio/cooldowns.py b/twitchio/cooldowns.py
deleted file mode 100644
index 9bbabef9..00000000
--- a/twitchio/cooldowns.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import asyncio
-import time
-
-
-class RateBucket:
- HTTPLIMIT = 800
- IRCLIMIT = 20
- MODLIMIT = 100
-
- HTTP = 60
- IRC = 30
-
- def __init__(self, *, method: str):
- self.method = method
-
- if method == "irc":
- self.reset_time = self.IRC
- self.limit = self.IRCLIMIT
- elif method == "mod":
- self.reset_time = self.IRC
- self.limit = self.MODLIMIT
- else:
- self.reset_time = self.HTTP
- self.limit = self.HTTPLIMIT
-
- self.tokens = 0
- self._reset = time.time() + self.reset_time
- self._event = asyncio.Event()
- self._event.set()
-
- @property
- def limited(self):
- return self.tokens >= self.limit
-
- def reset(self):
- self.tokens = 0
- self._reset = time.time() + self.reset_time
-
- def limit_until(self, t):
- """
- artificially causes a limit until t
- """
- self.tokens = self.limit
- self._reset = t
-
- def update(self, *, reset=None, remaining=None):
- now = time.time()
-
- if self._reset <= now:
- self.reset()
-
- if reset:
- self._reset = int(reset)
-
- if remaining:
- self.tokens = self.limit - int(remaining)
- else:
- self.tokens += 1
-
- async def wait_reset(self):
- await self._wait()
-
- def __await__(self):
- return self._wait()
-
- async def _wait(self):
- if self.tokens < self.limit:
- if self._event.is_set():
- self._event.clear()
-
- return
-
- if not self._event.is_set():
- await self._event.wait()
- return
- else:
- now = time.time()
- await asyncio.sleep(self._reset - now)
- self.reset()
- self._event.set()
diff --git a/twitchio/enums.py b/twitchio/enums.py
deleted file mode 100644
index 4978adc3..00000000
--- a/twitchio/enums.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import enum
-
-
-__all__ = ("PredictionEnum", "BroadcasterTypeEnum", "UserTypeEnum", "ModEventEnum")
-
-
-class PredictionEnum(enum.Enum):
- blue_1 = "blue-1"
- pink_2 = "pink-2"
-
-
-class BroadcasterTypeEnum(enum.Enum):
- partner = "partner"
- affiliate = "affiliate"
- none = ""
-
-
-class UserTypeEnum(enum.Enum):
- staff = "staff"
- admin = "admin"
- global_mod = "global_mod"
- none = ""
-
-
-class ModEventEnum(enum.Enum):
- moderator_remove = "moderation.moderator.remove"
- moderator_add = "moderation.moderator.add"
diff --git a/twitchio/errors.py b/twitchio/errors.py
deleted file mode 100644
index 77e81f82..00000000
--- a/twitchio/errors.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from typing import Any, Optional
-
-
-__all__ = (
- "TwitchIOException",
- "AuthenticationError",
- "InvalidContent",
- "IRCCooldownError",
- "EchoMessageWarning",
- "NoClientID",
- "NoToken",
- "HTTPException",
- "Unauthorized",
-)
-
-
-class TwitchIOException(Exception):
- pass
-
-
-class AuthenticationError(TwitchIOException):
- pass
-
-
-class InvalidContent(TwitchIOException):
- pass
-
-
-class IRCCooldownError(TwitchIOException):
- pass
-
-
-class EchoMessageWarning(TwitchIOException):
- pass
-
-
-class NoClientID(TwitchIOException):
- pass
-
-
-class NoToken(TwitchIOException):
- pass
-
-
-class HTTPException(TwitchIOException):
- def __init__(
- self, message: str, *, reason: Optional[str] = None, status: Optional[int] = None, extra: Optional[Any] = None
- ):
- self.message = f"{status}: {message}: {reason}"
- self.reason = reason
- self.status = status
- self.extra = extra
-
- super().__init__(self.message)
-
-
-class Unauthorized(HTTPException):
- pass
diff --git a/twitchio/events.pyi b/twitchio/events.pyi
new file mode 100644
index 00000000..6ed84c4f
--- /dev/null
+++ b/twitchio/events.pyi
@@ -0,0 +1,126 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import twitchio
+
+ from .authentication import UserTokenPayload
+
+async def event_oauth_authorized(payload: UserTokenPayload) -> None:
+ """Event dispatched when a user authorizes your Client-ID via Twitch OAuth on a built-in web adapter.
+
+ The default behaviour of this event is to add the authorized token to the client.
+ See: :class:`~twitchio.Client.add_token` for more details.
+
+ Parameters
+ ----------
+ payload: UserTokenPayload
+ """
+
+async def event_ready() -> None:
+ """Event dispatched when the Client is ready and has completed login."""
+
+# Eventsub Events
+
+# AutoMod
+async def event_automod_message_hold(payload: twitchio.AutomodMessageHold) -> None: ...
+async def event_automod_message_update(payload: twitchio.AutomodMessageUpdate) -> None: ...
+async def event_automod_settings_update() -> None: ...
+async def event_automod_terms_update() -> None: ...
+
+# Channel/Broadcaster
+async def event_channel_update() -> None: ...
+async def event_follow() -> None: ...
+async def event_ad_break() -> None: ...
+async def event_chat_clear() -> None: ...
+async def event_chat_clear_user() -> None: ...
+async def event_cheer() -> None: ...
+async def event_raid() -> None: ...
+
+# Messages/Chat
+async def event_message() -> None: ...
+async def event_message_whisper() -> None: ...
+async def event_message_delete() -> None: ...
+async def event_chat_notification() -> None: ...
+async def event_chat_settings_update() -> None: ...
+async def event_chat_user_message_hold() -> None: ...
+async def event_chat_user_message_update() -> None: ...
+
+# Shared Chat
+async def event_shared_chat_begin() -> None: ...
+async def event_shared_chat_update() -> None: ...
+async def event_shared_chat_end() -> None: ...
+
+# Subscriptions
+async def event_subscription() -> None: ...
+async def event_subscription_end() -> None: ...
+async def event_subscription_gift() -> None: ...
+async def event_subscription_message() -> None: ...
+
+# Bans
+async def event_ban() -> None: ...
+async def event_unban() -> None: ...
+async def event_unban_request() -> None: ...
+async def event_unban_request_resolve() -> None: ...
+
+# Warnings
+async def event_warning_acknowledge() -> None: ...
+async def event_warning_send() -> None: ...
+
+# Moderation and VIPs
+async def event_mod_action() -> None: ...
+async def event_moderator_add() -> None: ...
+async def event_moderator_remove() -> None: ...
+async def event_vip_add() -> None: ...
+async def event_vip_remove() -> None: ...
+
+# Redemptions and Rewards
+async def event_automatic_redemption_add() -> None: ...
+async def event_custom_reward_add() -> None: ...
+async def event_custom_reward_update() -> None: ...
+async def event_custom_reward_remove() -> None: ...
+async def event_custom_redemption_add() -> None: ...
+async def event_custom_redemption_update() -> None: ...
+
+# Polls
+async def event_poll_begin() -> None: ...
+async def event_poll_progress() -> None: ...
+async def event_poll_end() -> None: ...
+
+# Suspicious Users
+async def event_suspicious_user_message() -> None: ...
+async def event_suspicious_user_update() -> None: ...
+
+# Charity Campaigns
+async def event_charity_campaign_donate() -> None: ...
+async def event_charity_campaign_start() -> None: ...
+async def event_charity_campaign_progress() -> None: ...
+async def event_charity_campaign_stop() -> None: ...
+
+# Goals
+async def event_goal_begin() -> None: ...
+async def event_goal_peogress() -> None: ...
+async def event_goal_end() -> None: ...
+
+# Hype Train
+async def event_hype_train() -> None: ...
+async def event_hype_train_progress() -> None: ...
+async def event_hype_train_end() -> None: ...
+
+# Shield Mode
+async def event_shield_mode_begin() -> None: ...
+async def event_shield_mode_end() -> None: ...
+
+# Shoutouts
+async def event_shoutout_create() -> None: ...
+async def event_shoutout_receive() -> None: ...
+
+# Streams
+async def event_stream_online(payload: twitchio.StreamOnline) -> None: ...
+async def event_stream_offline(payload: twitchio.StreamOffline) -> None: ...
+
+# OAuth
+async def event_user_authorization_grant() -> None: ...
+async def event_user_authorization_revoke() -> None: ...
+
+# Users
+async def event_user_update() -> None: ...
diff --git a/twitchio/eventsub/__init__.py b/twitchio/eventsub/__init__.py
new file mode 100644
index 00000000..b1096b13
--- /dev/null
+++ b/twitchio/eventsub/__init__.py
@@ -0,0 +1,26 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from .enums import *
+from .subscriptions import *
diff --git a/twitchio/eventsub/enums.py b/twitchio/eventsub/enums.py
new file mode 100644
index 00000000..2e488a4b
--- /dev/null
+++ b/twitchio/eventsub/enums.py
@@ -0,0 +1,158 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+import enum
+
+
+__all__ = (
+ "CloseCode",
+ "MessageType",
+ "RevocationReason",
+ "ShardStatus",
+ "SubscriptionType",
+ "TransportMethod",
+)
+
+
+class TransportMethod(enum.Enum):
+ WEBHOOK = "webhook"
+ WEBSOCKET = "websocket"
+ CONDUIT = "conduit"
+
+
+class ShardStatus(enum.Enum):
+ ENABLED = "enabled"
+ WEBHOOK_VERIFICATION_PENDING = "webhook_callback_verification_pending"
+ WEBHOOK_VERIFICATION_FAILED = "webhook_callback_verification_failed"
+ NOTIFICATION_FAILURES_EXCEEDED = "notification_failures_exceeded"
+ WEBSOCKET_DISCONNECTED = "websocket_disconnected"
+ WEBSOCKET_FAILIED_PING = "websocket_failed_ping_pong"
+ WEBSOCKET_RECEIVED_INBOUD = "websocket_received_inbound_traffic"
+ WEBSOCKET_INTERNAL_ERROR = "websocket_internal_error"
+ WEBSOCKET_NETWORK_TIMEOUT = "websocket_network_timeout"
+ WEBSOCKET_NETWORK_ERROR = "websocket_network_error"
+ WEBSOCKET_FAILED_RECONNECT = "websocket_failed_to_reconnect"
+
+
+class CloseCode(enum.Enum):
+ INTERNAL_SERVER_ERROR = 4000
+ SENT_INBOUND_TRAFFIC = 4001
+ FAILED_PING = 4002
+ CONNECTION_UNUSED = 4003
+ RECONNECT_GRACE_TIMEOUT = 4004
+ NETWORK_TIMEOUT = 4005
+ NETWORK_ERROR = 4006
+ INVALID_RECONNECT = 4007
+
+
+class MessageType(enum.Enum):
+ SESSION_WELCOME = "session_welcome"
+ SESSION_KEEPALIVE = "session_keepalive"
+ NOTIFICATION = "notification"
+ SESSION_RECONNECT = "session_reconnect"
+ REVOCATION = "revocation"
+
+
+class SubscriptionType(enum.Enum):
+ AutomodMessageHold = "automod.message.hold"
+ AutomodMessageUpdate = "automod.message.update"
+ AutomodSettingsUpdate = "automod.settings.update"
+ AutomodTermsUpdate = "automod.terms.update"
+ ChannelUpdate = "channel.update"
+ ChannelFollow = "channel.follow"
+ ChannelAdBreakBegin = "channel.ad_break.begin"
+ ChannelChatClear = "channel.chat.clear"
+ ChannelChatClearUserMessages = "channel.chat.clear_user_messages"
+ ChannelChatMessage = "channel.chat.message"
+ ChannelChatMessageDelete = "channel.chat.message_delete"
+ ChannelChatNotification = "channel.chat.notification"
+ ChannelChatSettingsUpdate = "channel.chat_settings.update"
+ ChannelChatUserMessageHold = "channel.chat.user_message_hold"
+ ChannelChatUserMessageUpdate = "channel.chat.user_message_update"
+ ChannelSubscribe = "channel.subscribe"
+ ChannelSubscriptionEnd = "channel.subscription.end"
+ ChannelSubscriptionGift = "channel.subscription.gift"
+ ChannelSubscriptionMessage = "channel.subscription.message"
+ ChannelCheer = "channel.cheer"
+ ChannelRaid = "channel.raid"
+ ChannelBan = "channel.ban"
+ ChannelUnban = "channel.unban"
+ ChannelUnbanRequestCreate = "channel.unban_request.create"
+ ChannelUnbanRequestResolve = "channel.unban_request.resolve"
+ ChannelModerate = "channel.moderate"
+ ChannelModeratorAdd = "channel.moderator.add"
+ ChannelModeratorRemove = "channel.moderator.remove"
+ ChannelGuestStarSessionBegin = "channel.guest_star_session.begin"
+ ChannelGuestStarSessionEnd = "channel.guest_star_session.end"
+ ChannelGuestStarGuestUpdate = "channel.guest_star_guest.update"
+ ChannelGuestStarSettingsUpdate = "channel.guest_star_settings.update"
+ ChannelChannelPointsAutomaticRewardRedemptionAdd = "channel.channel_points_automatic_reward_redemption.add"
+ ChannelChannelPointsCustomRewardAdd = "channel.channel_points_custom_reward.add"
+ ChannelChannelPointsCustomRewardUpdate = "channel.channel_points_custom_reward.update"
+ ChannelChannelPointsCustomRewardRemove = "channel.channel_points_custom_reward.remove"
+ ChannelChannelPointsCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
+ ChannelChannelPointsCustomRewardRedemptionUpdate = "channel.channel_points_custom_reward_redemption.update"
+ ChannelPollBegin = "channel.poll.begin"
+ ChannelPollProgress = "channel.poll.progress"
+ ChannelPollEnd = "channel.poll.end"
+ ChannelPredictionBegin = "channel.prediction.begin"
+ ChannelPredictionProgress = "channel.prediction.progress"
+ ChannelPredictionLock = "channel.prediction.lock"
+ ChannelPredictionEnd = "channel.prediction.end"
+ ChannelSuspiciousUserMessage = "channel.suspicious_user.message"
+ ChannelSuspiciousUserUpdate = "channel.suspicious_user.update"
+ ChannelVipAdd = "channel.vip.add"
+ ChannelVipRemove = "channel.vip.remove"
+ ChannelCharityCampaignDonate = "channel.charity_campaign.donate"
+ ChannelCharityCampaignStart = "channel.charity_campaign.start"
+ ChannelCharityCampaignProgress = "channel.charity_campaign.progress"
+ ChannelCharityCampaignStop = "channel.charity_campaign.stop"
+ ConduitShardDisabled = "conduit.shard.disabled"
+ DropEntitlementGrant = "drop.entitlement.grant"
+ ExtensionBitsTransactionCreate = "extension.bits_transaction.create"
+ ChannelGoalBegin = "channel.goal.begin"
+ ChannelGoalProgress = "channel.goal.progress"
+ ChannelGoalEnd = "channel.goal.end"
+ ChannelHypeTrainBegin = "channel.hype_train.begin"
+ ChannelHypeTrainProgress = "channel.hype_train.progress"
+ ChannelHypeTrainEnd = "channel.hype_train.end"
+ ChannelShieldModeBegin = "channel.shield_mode.begin"
+ ChannelShieldModeEnd = "channel.shield_mode.end"
+ ChannelShoutoutCreate = "channel.shoutout.create"
+ ChannelShoutoutReceive = "channel.shoutout.receive"
+ ChannelWarningAcknowledgement = "channel.warning.acknowledge"
+ ChannelWarningSend = "channel.warning.send"
+ StreamOnline = "stream.online"
+ StreamOffline = "stream.offline"
+ UserAuthorizationGrant = "user.authorization.grant"
+ UserAuthorizationRevoke = "user.authorization.revoke"
+ UserUpdate = "user.update"
+ UserWhisperMessage = "user.whisper.message"
+
+
+class RevocationReason(enum.Enum):
+ USER_REMOVED = "user_removed"
+ AUTHORIZATION_REVOKED = "authorization_revoked"
+ NOTIFICATION_FAILURES_EXCEEDED = "notification_failures_exceeded"
+ VERSION_REMOVED = "version_removed"
diff --git a/twitchio/eventsub/subscriptions.py b/twitchio/eventsub/subscriptions.py
new file mode 100644
index 00000000..2b4447b5
--- /dev/null
+++ b/twitchio/eventsub/subscriptions.py
@@ -0,0 +1,2917 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import abc
+from typing import TYPE_CHECKING, Any, ClassVar, Literal, Unpack
+
+from twitchio.utils import handle_user_ids
+
+
+if TYPE_CHECKING:
+ from ..types_.conduits import Condition
+
+
+__all__ = (
+ "AdBreakBeginSubscription",
+ "AutomodMessageHoldSubscription",
+ "AutomodMessageHoldV2Subscription",
+ "AutomodMessageUpdateSubscription",
+ "AutomodMessageUpdateV2Subscription",
+ "AutomodSettingsUpdateSubscription",
+ "AutomodTermsUpdateSubscription",
+ "ChannelBanSubscription",
+ "ChannelCheerSubscription",
+ "ChannelFollowSubscription",
+ "ChannelModerateSubscription",
+ "ChannelModerateV2Subscription",
+ "ChannelModeratorAddSubscription",
+ "ChannelModeratorRemoveSubscription",
+ "ChannelPointsAutoRedeemSubscription",
+ "ChannelPointsRedeemAddSubscription",
+ "ChannelPointsRedeemUpdateSubscription",
+ "ChannelPointsRewardAddSubscription",
+ "ChannelPointsRewardRemoveSubscription",
+ "ChannelPointsRewardUpdateSubscription",
+ "ChannelPollBeginSubscription",
+ "ChannelPollEndSubscription",
+ "ChannelPollProgressSubscription",
+ "ChannelPredictionBeginSubscription",
+ "ChannelPredictionEndSubscription",
+ "ChannelPredictionLockSubscription",
+ "ChannelPredictionProgressSubscription",
+ "ChannelRaidSubscription",
+ "ChannelSubscribeMessageSubscription",
+ "ChannelSubscribeSubscription",
+ "ChannelSubscriptionEndSubscription",
+ "ChannelSubscriptionGiftSubscription",
+ "ChannelUnbanRequestResolveSubscription",
+ "ChannelUnbanRequestSubscription",
+ "ChannelUnbanSubscription",
+ "ChannelUpdateSubscription",
+ "ChannelVIPAddSubscription",
+ "ChannelVIPRemoveSubscription",
+ "ChannelWarningAcknowledgementSubscription",
+ "ChannelWarningSendSubscription",
+ "CharityCampaignProgressSubscription",
+ "CharityCampaignStartSubscription",
+ "CharityCampaignStopSubscription",
+ "CharityDonationSubscription",
+ "ChatClearSubscription",
+ "ChatClearUserMessagesSubscription",
+ "ChatMessageDeleteSubscription",
+ "ChatMessageSubscription",
+ "ChatNotificationSubscription",
+ "ChatSettingsUpdateSubscription",
+ "ChatUserMessageHoldSubscription",
+ "ChatUserMessageUpdateSubscription",
+ "GoalBeginSubscription",
+ "GoalEndSubscription",
+ "GoalProgressSubscription",
+ "HypeTrainBeginSubscription",
+ "HypeTrainEndSubscription",
+ "HypeTrainProgressSubscription",
+ "SharedChatSessionBeginSubscription",
+ "SharedChatSessionEndSubscription",
+ "SharedChatSessionUpdateSubscription",
+ "ShieldModeBeginSubscription",
+ "ShieldModeEndSubscription",
+ "ShoutoutCreateSubscription",
+ "ShoutoutReceiveSubscription",
+ "StreamOfflineSubscription",
+ "StreamOnlineSubscription",
+ "SubscriptionPayload",
+ "SuspiciousUserMessageSubscription",
+ "SuspiciousUserUpdateSubscription",
+ "UserAuthorizationGrantSubscription",
+ "UserAuthorizationRevokeSubscription",
+ "UserUpdateSubscription",
+ "WhisperReceivedSubscription",
+)
+
+
+# Short names: Only map names that require shortening...
+_SUB_MAPPING: dict[str, str] = {
+ "channel.ad_break.begin": "ad_break",
+ "channel.chat.clear_user_messages": "chat_clear_user",
+ "channel.chat.message": "message", # Sub events?
+ "channel.chat.message_delete": "message_delete",
+ "channel.unban_request.create": "unban_request",
+ "channel.channel_points_automatic_reward_redemption.add": "automatic_redemption_add",
+ "channel.channel_points_custom_reward.add": "custom_reward_add",
+ "channel.channel_points_custom_reward.update": "custom_reward_update",
+ "channel.channel_points_custom_reward.remove": "custom_reward_remove",
+ "channel.channel_points_custom_reward_redemption.add": "custom_redemption_add",
+ "channel.channel_points_custom_reward_redemption.update": "custom_redemption_update",
+ "user.whisper.message": "message_whisper",
+ "channel.update": "channel_update",
+ "channel.subscribe": "subscription",
+ "channel.moderate": "mod_action", # Sub events?
+ "channel.hype_train.begin": "hype_train",
+}
+
+
+class SubscriptionPayload(abc.ABC):
+ type: ClassVar[Any]
+ version: ClassVar[Any]
+
+ __slots__ = (
+ "broadcaster_id",
+ "broadcaster_user_id",
+ "campaign_id",
+ "category_id",
+ "client_id",
+ "conduit_id",
+ "from_broadcaster_user_id",
+ "moderator_user_id",
+ "organization_id",
+ "reward_id",
+ "to_broadcaster_user_id",
+ "user_id",
+ )
+
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ raise NotImplementedError
+
+ @property
+ def condition(self) -> Condition:
+ raise NotImplementedError
+
+
+class AutomodMessageHoldSubscription(SubscriptionPayload):
+ """The ``automod.message.hold`` subscription type notifies a user if a message was caught by automod for review.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:manage:automod scope``. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:manage:automod`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.message.hold"]] = "automod.message.hold"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AutomodMessageHoldV2Subscription(SubscriptionPayload):
+ """The ``automod.message.hold`` V2 subscription type notifies a user if a message was caught by automod for review.
+
+ Version 2 of this endpoint provides additional information about the message, including the reason, the term used, and its position within the message.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:manage:automod scope``. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:manage:automod`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.message.hold"]] = "automod.message.hold"
+ version: ClassVar[Literal["2"]] = "2"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AutomodMessageUpdateSubscription(SubscriptionPayload):
+ """The ``automod.message.update`` subscription type sends notification when a message in the automod queue has its status changed.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:manage:automod scope``. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:manage:automod`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.message.update"]] = "automod.message.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AutomodMessageUpdateV2Subscription(SubscriptionPayload):
+ """The ``automod.message.update`` subscription type sends notification when a message in the automod queue has its status changed.
+
+ Version 2 of this endpoint provides additional information about the message, including the reason, the term used, and its position within the message.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:manage:automod scope``. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:manage:automod`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.message.update"]] = "automod.message.update"
+ version: ClassVar[Literal["2"]] = "2"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AutomodSettingsUpdateSubscription(SubscriptionPayload):
+ """The ``automod.settings.update`` subscription type sends a notification when a broadcaster's automod settings are updated.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:read:automod_settings`` scope. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:read:automod_settings`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.settings.update"]] = "automod.settings.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AutomodTermsUpdateSubscription(SubscriptionPayload):
+ """The ``automod.terms.update`` subscription type sends a notification when a broadcaster's terms settings are updated. Changes to private terms are not sent.
+
+ .. important::
+ Requires a user access token that includes the ``moderator:manage:automod`` scope. The ID in the ``moderator_user_id`` condition parameter must match the user ID in the access token.
+
+ If app access token used, then additionally requires the ``moderator:manage:automod`` scope for the moderator.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["automod.terms.update"]] = "automod.terms.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelUpdateSubscription(SubscriptionPayload):
+ """The ``channel.update`` subscription type sends notifications when a broadcaster updates the category, title, content classification labels, or broadcast language for their channel.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.update"]] = "channel.update"
+ version: ClassVar[Literal["2"]] = "2"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelFollowSubscription(SubscriptionPayload):
+ """The ``channel.follow`` subscription type sends a notification when a specified channel receives a follow.
+
+ .. important::
+ Must have ``moderator:read:followers`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.follow"]] = "channel.follow"
+ version: ClassVar[Literal["2"]] = "2"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class AdBreakBeginSubscription(SubscriptionPayload):
+ """The ``channel.ad_break.begin`` subscription type sends a notification when a user runs a midroll commercial break, either manually or automatically via ads manager.
+
+ .. important::
+ Must have ``channel:read:ads`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.ad_break.begin"]] = "channel.ad_break.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChatClearSubscription(SubscriptionPayload):
+ """The ``channel.chat.clear`` subscription type sends a notification when a moderator or bot clears all messages from the chat room.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.clear"]] = "channel.chat.clear"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatClearUserMessagesSubscription(SubscriptionPayload):
+ """The ``channel.chat.clear_user_messages`` subscription type sends a notification when a moderator or bot clears all messages for a specific user.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.clear_user_messages"]] = "channel.chat.clear_user_messages"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatMessageSubscription(SubscriptionPayload):
+ """The ``channel.chat.message`` subscription type sends a notification when any user sends a message to a channel's chat room.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.message"]] = "channel.chat.message"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatNotificationSubscription(SubscriptionPayload):
+ """The ``channel.chat.notification`` subscription type sends a notification when an event that appears in chat occurs, such as someone subscribing to the channel or a subscription is gifted.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.notification"]] = "channel.chat.notification"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatMessageDeleteSubscription(SubscriptionPayload):
+ """The ``channel.chat.message_delete`` subscription type sends a notification when a moderator removes a specific message.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.message_delete"]] = "channel.chat.message_delete"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatSettingsUpdateSubscription(SubscriptionPayload):
+ """The ``channel.chat_settings.update`` subscription type sends a notification when a broadcaster's chat settings are updated.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user, and either ``channel:bot`` scope from broadcaster or moderator status.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat_settings.update"]] = "channel.chat_settings.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatUserMessageHoldSubscription(SubscriptionPayload):
+ """The ``channel.chat.user_message_hold`` subscription type notifies a user if their message is caught by automod.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.user_message_hold"]] = "channel.chat.user_message_hold"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class ChatUserMessageUpdateSubscription(SubscriptionPayload):
+ """The ``channel.chat.user_message_update`` subscription type notifies a user if their message's automod status is updated.
+
+ .. important::
+ Requires ``user:read:chat`` scope from chatting user.
+
+ If app access token used, then additionally requires ``user:bot`` scope from chatting user.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the chatter reading chat. e.g. Your bot ID.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.chat.user_message_update"]] = "channel.chat.user_message_update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.broadcaster_user_id or not self.user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "user_id": self.user_id}
+
+
+class SharedChatSessionBeginSubscription(SubscriptionPayload):
+ """The ``channel.shared_chat.begin`` subscription type sends a notification when a channel becomes active in an active shared chat session.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shared_chat.begin"]] = "channel.shared_chat.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class SharedChatSessionUpdateSubscription(SubscriptionPayload):
+ """The ``channel.shared_chat.update`` subscription type sends a notification when the active shared chat session the channel is in changes.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shared_chat.update"]] = "channel.shared_chat.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class SharedChatSessionEndSubscription(SubscriptionPayload):
+ """The ``channel.shared_chat.end`` subscription type sends a notification when a channel leaves a shared chat session or the session ends.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shared_chat.end"]] = "channel.shared_chat.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelSubscribeSubscription(SubscriptionPayload):
+ """The ``channel.subscribe`` subscription type sends a notification when a specified channel receives a subscriber. This does not include resubscribes.
+
+ .. important::
+ Must have ``channel:read:subscriptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.subscribe"]] = "channel.subscribe"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelSubscriptionEndSubscription(SubscriptionPayload):
+ """The ``channel.subscription.end`` subscription type sends a notification when a subscription to the specified channel expires.
+
+ .. important::
+ Must have ``channel:read:subscriptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.subscription.end"]] = "channel.subscription.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelSubscriptionGiftSubscription(SubscriptionPayload):
+ """The ``channel.subscription.gift`` subscription type sends a notification when a user gives one or more gifted subscriptions in a channel.
+
+ .. important::
+ Must have ``channel:read:subscriptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.subscription.gift"]] = "channel.subscription.gift"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelSubscribeMessageSubscription(SubscriptionPayload):
+ """The ``channel.subscription.message`` subscription type sends a notification when a user sends a resubscription chat message in a specific channel.
+
+ .. important::
+ Must have ``channel:read:subscriptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.subscription.message"]] = "channel.subscription.message"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelCheerSubscription(SubscriptionPayload):
+ """The ``channel.cheer`` subscription type sends a notification when a user cheers on the specified channel.
+
+ .. important::
+ Must have ``bits:read`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.cheer"]] = "channel.cheer"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelRaidSubscription(SubscriptionPayload):
+ """The ``channel.raid`` subscription type sends a notification when a broadcaster raids another broadcaster's channel.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ to_broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to. This listens to the raid events to a specific broadcaster.
+ from_broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to. This listens to the raid events from a specific broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameter "to_broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.raid"]] = "channel.raid"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.to_broadcaster_user_id: str = condition.get("to_broadcaster_user_id", "")
+ self.from_broadcaster_user_id: str = condition.get("from_broadcaster_user_id", "")
+
+ if bool(self.to_broadcaster_user_id) == bool(self.from_broadcaster_user_id):
+ raise ValueError(
+ 'Exactly one of the parameters "to_broadcaster_user_id" or "from_broadcaster_user_id" must be passed.'
+ )
+
+ @property
+ def condition(self) -> Condition:
+ return {
+ "to_broadcaster_user_id": self.to_broadcaster_user_id,
+ "from_broadcaster_user_id": self.from_broadcaster_user_id,
+ }
+
+
+class ChannelBanSubscription(SubscriptionPayload):
+ """The ``channel.ban`` subscription type sends a notification when a viewer is timed out or banned from the specified channel.
+
+ .. important::
+ Must have ``channel:moderate`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.ban"]] = "channel.ban"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelUnbanSubscription(SubscriptionPayload):
+ """The ``channel.unban`` subscription type sends a notification when a viewer is unbanned from the specified channel.
+
+ .. important::
+ Must have ``channel:moderate`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.unban"]] = "channel.unban"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelUnbanRequestSubscription(SubscriptionPayload):
+ """The ``channel.unban_request.create`` subscription type sends a notification when a user creates an unban request.
+
+ .. important::
+ Must have ``moderator:read:unban_requests`` or ``moderator:manage:unban_requests`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.unban_request.create"]] = "channel.unban_request.create"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelUnbanRequestResolveSubscription(SubscriptionPayload):
+ """The ``channel.unban_request.resolve`` subscription type sends a notification when an unban request has been resolved.
+
+ .. important::
+ Must have ``moderator:read:unban_requests`` or ``moderator:manage:unban_requests`` scope.
+
+ If you use webhooks, the user in moderator_user_id must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ If you use WebSockets, the ID in moderator_user_id must match the user ID in the user access token.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.unban_request.resolve"]] = "channel.unban_request.resolve"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelModerateSubscription(SubscriptionPayload):
+ """The ``channel.moderate`` subscription type sends a notification when a moderator performs a moderation action in a channel.
+ Some of these actions affect chatters in other channels during Shared Chat.
+
+ This is Version 1 of the subscription.
+
+ .. important::
+ Must have all of the following scopes:
+
+ - ``moderator:read:blocked_terms`` OR ``moderator:manage:blocked_terms``
+ - ``moderator:read:chat_settings`` OR ``moderator:manage:chat_settings``
+ - ``moderator:read:unban_requests`` OR ``moderator:manage:unban_requests``
+ - ``moderator:read:banned_users`` OR ``moderator:manage:banned_users``
+ - ``moderator:read:chat_messages`` OR ``moderator:manage:chat_messages``
+ - ``moderator:read:moderators``
+ - ``moderator:read:vips``
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.moderate"]] = "channel.moderate"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelModerateV2Subscription(SubscriptionPayload):
+ """The ``channel.moderate`` subscription type sends a notification when a moderator performs a moderation action in a channel.
+ Some of these actions affect chatters in other channels during Shared Chat.
+
+ This is Version 2 of the subscription that includes warnings.
+
+ .. important::
+ Must have all of the following scopes:
+
+ - ``moderator:read:blocked_terms`` OR ``moderator:manage:blocked_terms``
+ - ``moderator:read:chat_settings`` OR ``moderator:manage:chat_settings``
+ - ``moderator:read:unban_requests`` OR ``moderator:manage:unban_requests``
+ - ``moderator:read:banned_users`` OR ``moderator:manage:banned_users``
+ - ``moderator:read:chat_messages`` OR ``moderator:manage:chat_messages``
+ - ``moderator:read:warnings`` OR ``moderator:manage:warnings``
+ - ``moderator:read:moderators``
+ - ``moderator:read:vips``
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.moderate"]] = "channel.moderate"
+ version: ClassVar[Literal["2"]] = "2"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelModeratorAddSubscription(SubscriptionPayload):
+ """The ``channel.moderator.add`` subscription type sends a notification when a user is given moderator privileges on a specified channel.
+
+ .. important::
+ Must have ``moderation:read`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.moderator.add"]] = "channel.moderator.add"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelModeratorRemoveSubscription(SubscriptionPayload):
+ """The ``channel.moderator.remove`` subscription type sends a notification when a user has moderator privileges removed on a specified channel.
+
+ .. important::
+ Must have ``moderation:read`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.moderator.remove"]] = "channel.moderator.remove"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPointsAutoRedeemSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_automatic_reward_redemption.add`` subscription type sends a notification when a viewer has redeemed an automatic channel points reward on the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_automatic_reward_redemption.add"]] = (
+ "channel.channel_points_automatic_reward_redemption.add"
+ )
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPointsRewardAddSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_custom_reward.add`` subscription type sends a notification when a custom channel points reward has been created for the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_custom_reward.add"]] = "channel.channel_points_custom_reward.add"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPointsRewardUpdateSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_custom_reward.update`` subscription type sends a notification when a custom channel points reward has been updated for the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ reward_id: str
+ Optional to only get notifications for a specific reward.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_custom_reward.update"]] = "channel.channel_points_custom_reward.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.reward_id: str = condition.get("reward_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "reward_id": self.reward_id}
+
+
+class ChannelPointsRewardRemoveSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_custom_reward.remove`` subscription type sends a notification when a custom channel points reward has been removed from the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ reward_id: str
+ Optional to only get notifications for a specific reward.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_custom_reward.remove"]] = "channel.channel_points_custom_reward.remove"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.reward_id: str = condition.get("reward_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "reward_id": self.reward_id}
+
+
+class ChannelPointsRedeemAddSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_custom_reward_redemption.add`` subscription type sends a notification when a viewer has redeemed a custom channel points reward on the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ reward_id: str
+ Optional to only get notifications for a specific reward.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_custom_reward_redemption.add"]] = (
+ "channel.channel_points_custom_reward_redemption.add"
+ )
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.reward_id: str = condition.get("reward_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "reward_id": self.reward_id}
+
+
+class ChannelPointsRedeemUpdateSubscription(SubscriptionPayload):
+ """The ``channel.channel_points_custom_reward_redemption.update`` subscription type sends a notification when a redemption of a channel points custom reward has been updated for the specified channel.
+
+ .. important::
+ Must have ``channel:read:redemptions`` or ``channel:manage:redemptions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ reward_id: str
+ Optional to only get notifications for a specific reward.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.channel_points_custom_reward_redemption.update"]] = (
+ "channel.channel_points_custom_reward_redemption.update"
+ )
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.reward_id: str = condition.get("reward_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "reward_id": self.reward_id}
+
+
+class ChannelPollBeginSubscription(SubscriptionPayload):
+ """The ``channel.poll.begin`` subscription type sends a notification when a poll begins on the specified channel.
+
+ .. important::
+ Must have ``channel:read:polls`` or ``channel:manage:polls`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.poll.begin"]] = "channel.poll.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPollProgressSubscription(SubscriptionPayload):
+ """The ``channel.poll.progress`` subscription type sends a notification when users respond to a poll on the specified channel.
+
+ .. important::
+ Must have ``channel:read:polls`` or ``channel:manage:polls`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.poll.progress"]] = "channel.poll.progress"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPollEndSubscription(SubscriptionPayload):
+ """The ``channel.poll.end`` subscription type sends a notification when a poll ends on the specified channel.
+
+ .. important::
+ Must have ``channel:read:polls`` or ``channel:manage:polls`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.poll.end"]] = "channel.poll.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPredictionBeginSubscription(SubscriptionPayload):
+ """The ``channel.prediction.begin`` subscription type sends a notification when a Prediction begins on the specified channel.
+
+ .. important::
+ Must have ``channel:read:predictions`` or ``channel:manage:predictions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.prediction.begin"]] = "channel.prediction.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPredictionLockSubscription(SubscriptionPayload):
+ """The ``channel.prediction.lock`` subscription type sends a notification when a Prediction is locked on the specified channel.
+
+ .. important::
+ Must have ``channel:read:predictions`` or ``channel:manage:predictions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.prediction.lock"]] = "channel.prediction.lock"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPredictionProgressSubscription(SubscriptionPayload):
+ """The ``channel.prediction.progress`` subscription type sends a notification when users participate in a Prediction on the specified channel.
+
+ .. important::
+ Must have ``channel:read:predictions`` or ``channel:manage:predictions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.prediction.progress"]] = "channel.prediction.progress"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelPredictionEndSubscription(SubscriptionPayload):
+ """The ``channel.prediction.end`` subscription type sends a notification when a Prediction ends on the specified channel.
+
+ .. important::
+ Must have ``channel:read:predictions`` or ``channel:manage:predictions`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.prediction.end"]] = "channel.prediction.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class SuspiciousUserUpdateSubscription(SubscriptionPayload):
+ """The ``channel.suspicious_user.update`` subscription type sends a notification when a suspicious user has been updated.
+
+ .. important::
+ Requires the ``moderator:read:suspicious_users scope``.
+
+ If you use webhooks, the user in moderator_user_id must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ If you use WebSockets, the ID in moderator_user_id must match the user ID in the user access token.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.suspicious_user.update"]] = "channel.suspicious_user.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class SuspiciousUserMessageSubscription(SubscriptionPayload):
+ """The ``channel.suspicious_user.message`` subscription type sends a notification when a chat message has been sent from a suspicious user.
+
+ .. important::
+ Requires the ``moderator:read:suspicious_users scope``.
+
+ If you use webhooks, the user in moderator_user_id must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ If you use WebSockets, the ID in moderator_user_id must match the user ID in the user access token.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.suspicious_user.message"]] = "channel.suspicious_user.message"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelVIPAddSubscription(SubscriptionPayload):
+ """The ``channel.vip.add`` subscription type sends a notification when a VIP is added to the channel.
+
+ .. important::
+ Must have ``channel:read:vips`` or ``channel:manage:vips`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.vip.add"]] = "channel.vip.add"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelVIPRemoveSubscription(SubscriptionPayload):
+ """The ``channel.vip.remove`` subscription type sends a notification when a VIP is removed from the channel.
+
+ .. important::
+ Must have ``channel:read:vips`` or ``channel:manage:vips`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.vip.remove"]] = "channel.vip.remove"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ChannelWarningAcknowledgementSubscription(SubscriptionPayload):
+ """The ``channel.warning.acknowledge`` subscription type sends a notification when a warning is acknowledged by a user.
+ Broadcasters and moderators can see the warning's details.
+
+ .. important::
+ Must have the ``moderator:read:warnings`` or ``moderator:manage:warnings`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.warning.acknowledge"]] = "channel.warning.acknowledge"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ChannelWarningSendSubscription(SubscriptionPayload):
+ """The ``channel.warning.send`` subscription type sends a notification when a warning is sent to a user.
+ Broadcasters and moderators can see the warning's details.
+
+ .. important::
+ Must have the ``moderator:read:warnings`` or ``moderator:manage:warnings`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.warning.send"]] = "channel.warning.send"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class CharityDonationSubscription(SubscriptionPayload):
+ """The ``channel.charity_campaign.donate`` subscription type sends a notification when a user donates to the broadcaster's charity campaign.
+
+ .. important::
+ Must have ``channel:read:charity`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.charity_campaign.donate"]] = "channel.charity_campaign.donate"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class CharityCampaignStartSubscription(SubscriptionPayload):
+ """The ``channel.charity_campaign.start`` subscription type sends a notification when the broadcaster starts a charity campaign.
+
+ .. note::
+ It's possible to receive this event after the Progress event.
+
+ .. important::
+ Must have ``channel:read:charity`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.charity_campaign.start"]] = "channel.charity_campaign.start"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class CharityCampaignProgressSubscription(SubscriptionPayload):
+ """The ``channel.charity_campaign.progress`` subscription type sends a notification when progress is made towards the campaign's goal or when the broadcaster changes the fundraising goal.
+
+ .. note::
+ It's possible to receive this event before the Start event.
+
+ To get donation information, subscribe to :meth:`CharityDonationSubscription` event.
+
+ .. important::
+ Must have ``channel:read:charity`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.charity_campaign.progress"]] = "channel.charity_campaign.progress"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class CharityCampaignStopSubscription(SubscriptionPayload):
+ """The ``channel.charity_campaign.stop`` subscription type sends a notification when the broadcaster stops a charity campaign.
+
+ .. important::
+ Must have ``channel:read:charity`` scope.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.charity_campaign.stop"]] = "channel.charity_campaign.stop"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class GoalBeginSubscription(SubscriptionPayload):
+ """The ``channel.goal.begin`` subscription type sends a notification when the specified broadcaster begins a goal.
+
+ .. note::
+ It's possible to receive the Begin event after receiving Progress events.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:goals``.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.goal.begin"]] = "channel.goal.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class GoalProgressSubscription(SubscriptionPayload):
+ """The ``channel.goal.progress`` subscription type sends a notification when progress is made towards the specified broadcaster's goal.
+ Progress could be positive (added followers) or negative (lost followers).
+
+ .. note::
+ It's possible to receive the Progress events before receiving the Begin event.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:goals``.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.goal.progress"]] = "channel.goal.progress"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class GoalEndSubscription(SubscriptionPayload):
+ """The ``channel.goal.end`` subscription type sends a notification when the specified broadcaster ends a goal.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:goals``.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.goal.end"]] = "channel.goal.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class HypeTrainBeginSubscription(SubscriptionPayload):
+ """The ``channel.hype_train.begin`` subscription type sends a notification when a Hype Train begins on the specified channel.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:hype_train``.
+
+ .. note::
+ EventSub does not make strong assurances about the order of message delivery, so it is possible to receive `channel.hype_train.progress` notifications before you receive the corresponding `channel.hype_train.begin` notification.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.hype_train.begin"]] = "channel.hype_train.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class HypeTrainProgressSubscription(SubscriptionPayload):
+ """The ``channel.hype_train.progress`` subscription type sends a notification when a Hype Train makes progress on the specified channel.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:hype_train``.
+
+ .. note::
+ EventSub does not make strong assurances about the order of message delivery, so it is possible to receive `channel.hype_train.progress` notifications before you receive the corresponding `channel.hype_train.begin` notification.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.hype_train.progress"]] = "channel.hype_train.progress"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class HypeTrainEndSubscription(SubscriptionPayload):
+ """The ``channel.hype_train.end`` subscription type sends a notification when a Hype Train ends on the specified channel.
+
+ .. important::
+ Requires a user OAuth access token with scope ``channel:read:hype_train``.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.hype_train.end"]] = "channel.hype_train.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class ShieldModeBeginSubscription(SubscriptionPayload):
+ """The ``channel.shield_mode.begin`` subscription type sends a notification when the broadcaster activates Shield Mode.
+
+ This event informs the subscriber that the broadcaster's moderation settings were changed based on the broadcaster's Shield Mode configuration settings.
+
+ .. important::
+ Requires the ``moderator:read:shield_mode`` or ``moderator:manage:shield_mode`` scope.
+
+ - If you use webhooks, the moderator must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ - If you use WebSockets, the moderator's ID must match the user ID in the user access token.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shield_mode.begin"]] = "channel.shield_mode.begin"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ShieldModeEndSubscription(SubscriptionPayload):
+ """The ``channel.shield_mode.end`` subscription type sends a notification when the broadcaster deactivates Shield Mode.
+
+ This event informs the subscriber that the broadcaster's moderation settings were changed back to the broadcaster's previous moderation settings.
+
+ .. important::
+ Requires the ``moderator:read:shield_mode`` or ``moderator:manage:shield_mode`` scope.
+
+ - If you use webhooks, the moderator must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ - If you use WebSockets, the moderator's ID must match the user ID in the user access token.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shield_mode.end"]] = "channel.shield_mode.end"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ShoutoutCreateSubscription(SubscriptionPayload):
+ """The ``channel.shoutout.create`` subscription type sends a notification when the specified broadcaster sends a shoutout.
+
+ .. important::
+ Requires the ``moderator:read:shoutouts`` or ``moderator:manage:shoutouts`` scope.
+
+ - If you use webhooks, the moderator must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ - If you use WebSockets, the moderator's ID must match the user ID in the user access token.
+
+ .. note::
+ This is only sent if Twitch posts the Shoutout to the broadcaster's activity feed.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shoutout.create"]] = "channel.shoutout.create"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster_user_id" and "moderator_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class ShoutoutReceiveSubscription(SubscriptionPayload):
+ """The ``channel.shoutout.receive`` subscription type sends a notification when the specified broadcaster receives a shoutout.
+
+ .. important::
+ Requires the ``moderator:read:shoutouts`` or ``moderator:manage:shoutouts`` scope.
+
+ - If you use webhooks, the moderator must have granted your app (client ID) one of the above permissions prior to your app subscribing to this subscription type.
+
+ - If you use WebSockets, the moderator's ID must match the user ID in the user access token.
+
+ .. note::
+ This is only sent if Twitch posts the Shoutout to the broadcaster's activity feed.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+ moderator_user_id: str | PartialUser
+ The ID, or PartialUser, of a moderator for the the broadcaster you are subscribing to. This could also be the broadcaster.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["channel.shoutout.receive"]] = "channel.shoutout.receive"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+ self.moderator_user_id: str = condition.get("moderator_user_id", "")
+
+ if not self.broadcaster_user_id or not self.moderator_user_id:
+ raise ValueError('The parameters "broadcaster" and "moderator" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id, "moderator_user_id": self.moderator_user_id}
+
+
+class StreamOnlineSubscription(SubscriptionPayload):
+ """The ``stream.online`` subscription type sends a notification when the specified broadcaster starts a stream.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["stream.online"]] = "stream.online"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class StreamOfflineSubscription(SubscriptionPayload):
+ """The ``stream.offline`` subscription type sends a notification when the specified broadcaster stops a stream.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ broadcaster_user_id: str | PartialUser
+ The ID, or PartialUser, of the broadcaster to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "broadcaster_user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["stream.offline"]] = "stream.offline"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.broadcaster_user_id: str = condition.get("broadcaster_user_id", "")
+
+ if not self.broadcaster_user_id:
+ raise ValueError('The parameter "broadcaster_user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"broadcaster_user_id": self.broadcaster_user_id}
+
+
+class UserAuthorizationGrantSubscription(SubscriptionPayload):
+ """The ``user.authorization.grant`` subscription type sends a notification when a user's authorization has been granted to your client id.
+
+ .. important::
+ This subscription type is **only** supported by **webhooks**, and cannot be used with WebSockets.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ client_id: str
+ Provided client_id must match the client id in the application access token.
+
+ Raises
+ ------
+ ValueError
+ The parameter "client_id" must be passed.
+ """
+
+ type: ClassVar[Literal["user.authorization.grant"]] = "user.authorization.grant"
+ version: ClassVar[Literal["1"]] = "1"
+
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.client_id: str = condition.get("client_id", "")
+
+ if not self.client_id:
+ raise ValueError('The parameter "client_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"client_id": self.client_id}
+
+
+class UserAuthorizationRevokeSubscription(SubscriptionPayload):
+ """The ``user.authorization.revoke`` subscription type sends a notification when a user's authorization has been revoked for your client id.
+ Use this `webhook` to meet government requirements for handling user data, such as GDPR, LGPD, or CCPA.
+
+ .. important::
+ This subscription type is **only** supported by **webhooks**, and cannot be used with WebSockets.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ client_id: str
+ Provided client_id must match the client id in the application access token.
+
+ Raises
+ ------
+ ValueError
+ The parameter "client_id" must be passed.
+ """
+
+ type: ClassVar[Literal["user.authorization.revoke"]] = "user.authorization.revoke"
+ version: ClassVar[Literal["1"]] = "1"
+
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.client_id: str = condition.get("client_id", "")
+
+ if not self.client_id:
+ raise ValueError('The parameter "client_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"client_id": self.client_id}
+
+
+class UserUpdateSubscription(SubscriptionPayload):
+ """The ``user.update`` subscription type sends a notification when user updates their account.
+
+ .. note::
+ No authorization required. If you have the ``user:read:email`` scope, the notification will include email field.
+
+ If the user no longer exists then the login attribute will be None.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the user receiving the whispers you wish to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["user.update"]] = "user.update"
+ version: ClassVar[Literal["1"]] = "1"
+
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.user_id:
+ raise ValueError('The parameter "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"user_id": self.user_id}
+
+
+class WhisperReceivedSubscription(SubscriptionPayload):
+ """The ``user.whisper.message`` subscription type sends a notification when a user receives a whisper.
+
+ .. important::
+ Must have oauth scope ``user:read:whispers`` or ``user:manage:whispers``.
+
+ One attribute ``.condition`` can be accessed from this class, which returns a mapping of the subscription
+ parameters provided.
+
+ Parameters
+ ----------
+ user_id: str | PartialUser
+ The ID, or PartialUser, of the user receiving the whispers you wish to subscribe to.
+
+ Raises
+ ------
+ ValueError
+ The parameter "user_id" must be passed.
+ """
+
+ type: ClassVar[Literal["user.whisper.message"]] = "user.whisper.message"
+ version: ClassVar[Literal["1"]] = "1"
+
+ @handle_user_ids()
+ def __init__(self, **condition: Unpack[Condition]) -> None:
+ self.user_id: str = condition.get("user_id", "")
+
+ if not self.user_id:
+ raise ValueError('The parameter "user_id" must be passed.')
+
+ @property
+ def condition(self) -> Condition:
+ return {"user_id": self.user_id}
diff --git a/twitchio/eventsub/websockets.py b/twitchio/eventsub/websockets.py
new file mode 100644
index 00000000..e38b442f
--- /dev/null
+++ b/twitchio/eventsub/websockets.py
@@ -0,0 +1,457 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+import logging
+from typing import TYPE_CHECKING, cast
+
+import aiohttp
+
+from ..backoff import Backoff
+from ..exceptions import HTTPException, WebsocketConnectionException
+from ..models.eventsub_ import SubscriptionRevoked, create_event_instance
+from ..types_.conduits import (
+ MessageTypes,
+ MetaData,
+ NotificationMessage,
+ ReconnectMessage,
+ RevocationMessage,
+ WebsocketMessages,
+ WelcomeMessage,
+ WelcomePayload,
+)
+from ..utils import (
+ MISSING,
+ _from_json, # type: ignore
+)
+from .subscriptions import _SUB_MAPPING
+
+
+if TYPE_CHECKING:
+ from ..authentication.tokens import ManagedHTTPClient
+ from ..client import Client
+ from ..types_.conduits import Condition
+ from ..types_.eventsub import SubscriptionResponse, _SubscriptionData
+
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+
+WSS: str = "wss://eventsub.wss.twitch.tv/ws"
+
+
+class Websocket:
+ __slots__ = (
+ "__subscription_cost",
+ "_backoff",
+ "_client",
+ "_closed",
+ "_connecting",
+ "_connection_tasks",
+ "_heartbeat",
+ "_http",
+ "_keep_alive_task",
+ "_keep_alive_timeout",
+ "_last_keepalive",
+ "_listen_task",
+ "_original_attempts",
+ "_ready",
+ "_reconnect_attempts",
+ "_session_id",
+ "_socket",
+ "_subscriptions",
+ "_token_for",
+ )
+
+ def __init__(
+ self,
+ *,
+ keep_alive_timeout: float = 10,
+ reconnect_attempts: int | None = MISSING,
+ client: Client | None = None,
+ token_for: str,
+ http: ManagedHTTPClient,
+ ) -> None:
+ self._keep_alive_timeout: int = max(10, min(int(keep_alive_timeout), 600))
+ self._heartbeat: int = min(self._keep_alive_timeout, 25) + 5
+ self._last_keepalive: datetime.datetime | None = None
+ self._keep_alive_task: asyncio.Task[None] | None = None
+
+ self._session_id: str | None = None
+
+ self._socket: aiohttp.ClientWebSocketResponse | None = None
+ self._listen_task: asyncio.Task[None] | None = None
+
+ self._ready: asyncio.Event = asyncio.Event()
+
+ attempts: int | None = (
+ 0 if reconnect_attempts is None else None if reconnect_attempts is MISSING else reconnect_attempts
+ )
+ self._original_attempts = reconnect_attempts
+ self._reconnect_attempts = attempts
+ self._backoff: Backoff = Backoff(base=3, maximum_time=90)
+
+ self.__subscription_cost: int = 0
+
+ self._client: Client | None = client
+ self._token_for: str = token_for
+ self._http: ManagedHTTPClient = http
+ self._subscriptions: dict[str, _SubscriptionData] = {}
+
+ self._connecting: bool = False
+ self._closed: bool = False
+
+ self._connection_tasks: set[asyncio.Task[None]] = set()
+
+ msg = "Websocket %s is being used without a Client/Bot. Event dispatching is disabled for this websocket."
+ if not client:
+ logger.warning(msg, self)
+
+ def __repr__(self) -> str:
+ return f"EventsubWebsocket(session_id={self._session_id})"
+
+ def __str__(self) -> str:
+ return f"{self._session_id}"
+
+ @property
+ def keep_alive_timeout(self) -> int:
+ return self._keep_alive_timeout
+
+ @property
+ def connected(self) -> bool:
+ return bool(self._socket and not self._socket.closed)
+
+ @property
+ def session_id(self) -> str | None:
+ return self._session_id
+
+ @property
+ def can_subscribe(self) -> bool:
+ return self.subscription_count < 300
+
+ @property
+ def subscription_count(self) -> int:
+ return len(self._subscriptions)
+
+ async def connect(self, *, url: str | None = None, reconnect: bool = False, fail_once: bool = False) -> None:
+ if self._closed or self._connecting:
+ return
+
+ self._connecting = True
+ url_: str = url or f"{WSS}?keepalive_timeout_seconds={self._keep_alive_timeout}"
+
+ self._ready.clear()
+
+ retries: int | None = self._reconnect_attempts
+ if retries == 0 and reconnect:
+ logger.info("Websocket <%s> was closed unexepectedly, but is flagged as 'should not reconnect'.", self)
+ return await self.close()
+
+ if reconnect:
+ # We have to ensure that the tokens we need for resubscribing have been recently refreshed as
+ # we only have 10 seconds to subscribe after we receive the welcome message...
+ await self._http._validated_event.wait()
+
+ while True:
+ try:
+ async with aiohttp.ClientSession() as session:
+ new = await session.ws_connect(url_, heartbeat=self._heartbeat)
+ session.detach()
+ except Exception as e:
+ logger.debug("Failed to connect to eventsub websocket <%s>: %s.", self, e)
+
+ if fail_once:
+ await self.close()
+ raise WebsocketConnectionException from e
+ else:
+ break
+
+ if retries == 0:
+ await self.close()
+
+ raise WebsocketConnectionException(
+ "Failed to connect to eventsub websocket <%s> after %s retries. "
+ "Please attempt to reconnect or re-subscribe this eventsub connection.",
+ self,
+ self._reconnect_attempts,
+ )
+
+ if retries is not None:
+ retries -= 1
+
+ delay: float = self._backoff.calculate()
+ logger.info('Websocket <%s> retrying to reconnect websocket connection in "%s" seconds.', self, delay)
+
+ await asyncio.sleep(delay)
+
+ if reconnect:
+ await self.close(cleanup=False)
+
+ self._socket = new
+
+ if not self._listen_task:
+ self._listen_task = asyncio.create_task(self._listen())
+
+ try:
+ async with asyncio.timeout(10 + 1):
+ await self._ready.wait()
+ except TimeoutError:
+ await self.close()
+
+ raise WebsocketConnectionException(
+ "Websocket <%s> did not receive a welcome message from Twitch within the allowed timeframe. "
+ "Please attempt to reconnect or re-subscribe this eventsub connection.",
+ self,
+ )
+
+ self._keep_alive_task = asyncio.create_task(self._process_keepalive())
+
+ if reconnect:
+ await self._resubscribe()
+
+ self._connecting = False
+
+ async def _resubscribe(self) -> None:
+ assert self._session_id
+
+ for identifier, sub in self._subscriptions.copy().items():
+ sub["transport"]["session_id"] = self._session_id
+
+ try:
+ resp: SubscriptionResponse = await self._http.create_eventsub_subscription(**sub)
+ except HTTPException as e:
+ if e.status == 409:
+ # This should never happen here...
+ # But we may as well handle it in-case of edge cases instead of being noisy...
+
+ msg: str = "Disregarding. Websocket '%s' tried to resubscribe to subscription '%s' but failed with 409."
+ logger.debug(msg, self, identifier)
+ continue
+
+ logger.error("Unable to resubscribe to subscription '%s' on websocket '%s': %s", identifier, self, e)
+ continue
+
+ for new in resp["data"]:
+ self._subscriptions[new["id"]] = sub
+
+ type_: str = sub["type"].value
+ version: str = sub["version"]
+ condition: Condition = sub["condition"]
+
+ msg: str = "Websocket '%s' successfully resubscribed to subscription '%s:%s' after reconnect: %s"
+ logger.debug(msg, self, type_, version, condition)
+
+ async def _reconnect(self, url: str) -> None:
+ socket: Websocket = Websocket(
+ keep_alive_timeout=self._keep_alive_timeout,
+ reconnect_attempts=self._original_attempts,
+ client=self._client,
+ token_for=self._token_for,
+ http=self._http,
+ )
+
+ socket._subscriptions = self._subscriptions
+
+ try:
+ await socket.connect(url=url, reconnect=False, fail_once=True)
+ except Exception:
+ return await self.connect(reconnect=True)
+
+ if self._client:
+ self._client._websockets[self._token_for][socket.session_id] = socket # type: ignore
+
+ await self.close()
+
+ def _create_connection_task(self) -> None:
+ task = asyncio.create_task(self.connect(reconnect=True))
+ self._connection_tasks.add(task)
+ task.add_done_callback(self._connection_tasks.discard)
+
+ async def _listen(self) -> None:
+ assert self._socket
+
+ while True:
+ try:
+ message: aiohttp.WSMessage = await self._socket.receive()
+ except Exception:
+ self._create_connection_task()
+ break
+
+ type_: aiohttp.WSMsgType = message.type
+ if type_ in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING):
+ logger.debug("Received close message [%s] on eventsub websocket: <%s>", self._socket.close_code, self)
+
+ if self._socket.close_code == 4001:
+ logger.critical(
+ "Websocket <%s> attempted to send an outgoing message to Twitch. "
+ "Twitch prohibits sending outgoing messages to the server, this will result in a disconnect. "
+ "This websocket will NOT attempt to reconnect.",
+ self,
+ )
+ return await self.close()
+
+ elif self._socket.close_code == 4003:
+ return await self.close()
+
+ self._create_connection_task()
+ break
+
+ if type_ is not aiohttp.WSMsgType.TEXT:
+ logger.debug("Received unknown message from eventsub websocket: <%s>", self)
+ continue
+
+ self._last_keepalive = datetime.datetime.now()
+
+ try:
+ data: WebsocketMessages = cast(WebsocketMessages, _from_json(message.data))
+ except Exception:
+ logger.warning("Unable to parse JSON in eventsub websocket: <%s>", self)
+ continue
+
+ metadata: MetaData = data["metadata"]
+ message_type: MessageTypes = metadata["message_type"]
+
+ if message_type == "session_welcome":
+ welcome_data: WelcomeMessage = cast(WelcomeMessage, data)
+
+ await self._process_welcome(welcome_data)
+
+ elif message_type == "session_reconnect":
+ logger.debug('Received "session_reconnect" message from eventsub websocket: <%s>', self)
+ reconnect_data: ReconnectMessage = cast(ReconnectMessage, data)
+
+ await self._process_reconnect(reconnect_data)
+
+ elif message_type == "session_keepalive":
+ logger.debug('Received "session_keepalive" message from eventsub websocket: <%s>', self)
+
+ elif message_type == "revocation":
+ logger.debug('Received "revocation" message from eventsub websocket: <%s>', self)
+
+ revocation_data: RevocationMessage = cast(RevocationMessage, data)
+ await self._process_revocation(revocation_data)
+
+ elif message_type == "notification":
+ logger.debug('Received "notification" message from eventsub websocket: <%s>. %s', self, data)
+ notification_data: NotificationMessage = cast(NotificationMessage, data)
+
+ try:
+ await self._process_notification(notification_data)
+ except Exception as e:
+ msg = "Caught an unknown exception while proccessing a websocket 'notification' event:\n%s\n"
+ logger.critical(msg, str(e), exc_info=e)
+
+ else:
+ logger.warning("Received an unknown message type in eventsub websocket: <%s>", self)
+
+ async def _process_keepalive(self) -> None:
+ assert self._last_keepalive
+ logger.debug("Started keep_alive task on eventsub websocket: <%s>", self)
+
+ while True:
+ await asyncio.sleep(self._keep_alive_timeout)
+ now: datetime.datetime = datetime.datetime.now()
+
+ if self._last_keepalive + datetime.timedelta(seconds=self._keep_alive_timeout + 5) < now:
+ self._create_connection_task()
+ return
+
+ async def _process_welcome(self, data: WelcomeMessage) -> None:
+ payload: WelcomePayload = data["payload"]
+ new_id: str = payload["session"]["id"]
+
+ if self._session_id:
+ self._cleanup(closed=False)
+ self._client._websockets[self._token_for] = {self.session_id: self} # type: ignore
+
+ self._session_id = new_id
+ self._ready.set()
+
+ assert self._listen_task
+
+ self._listen_task.set_name(f"EventsubWebsocketListener: {self._session_id}")
+ logger.info('Received "session_welcome" message from eventsub websocket: <%s>', self)
+
+ async def _process_reconnect(self, data: ReconnectMessage) -> None:
+ logger.info("Attempting to reconnect eventsub websocket due to a reconnect message from Twitch: <%s>", self)
+ await self._reconnect(url=data["payload"]["session"]["reconnect_url"])
+
+ async def _process_revocation(self, data: RevocationMessage) -> None:
+ payload: SubscriptionRevoked = SubscriptionRevoked(data=data["payload"]["subscription"])
+
+ if self._client:
+ self._client.dispatch(event="subscription_revoked", payload=payload)
+
+ self._subscriptions.pop(payload.id, None)
+
+ async def _process_notification(self, data: NotificationMessage) -> None:
+ sub_type = data["metadata"]["subscription_type"]
+ event = _SUB_MAPPING.get(sub_type, sub_type.removeprefix("channel.")).replace(".", "_")
+
+ try:
+ payload_class = create_event_instance(sub_type, data, http=self._http)
+ except ValueError:
+ logger.warning("Websocket '%s' received an unhandled eventsub event: '%s'.", self, event)
+ return
+
+ if self._client:
+ self._client.dispatch(event=event, payload=payload_class)
+
+ def _cleanup(self, closed: bool = True) -> None:
+ self._closed = closed
+
+ if self._client:
+ sockets = self._client._websockets.get(self._token_for, {})
+ sockets.pop(self.session_id or "", None)
+
+ async def close(self, cleanup: bool = True) -> None:
+ if cleanup:
+ self._cleanup()
+
+ if self._keep_alive_task:
+ try:
+ self._keep_alive_task.cancel()
+ except Exception:
+ pass
+
+ if self._listen_task:
+ try:
+ self._listen_task.cancel()
+ except Exception:
+ pass
+
+ if self._socket:
+ try:
+ await self._socket.close()
+ except Exception:
+ pass
+
+ self._keep_alive_task = None
+ self._listen_task = None
+ self._socket = None
+
+ logger.debug("Successfully closed eventsub websocket: <%s>", self)
diff --git a/twitchio/exceptions.py b/twitchio/exceptions.py
new file mode 100644
index 00000000..5c332eb1
--- /dev/null
+++ b/twitchio/exceptions.py
@@ -0,0 +1,156 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+
+__all__ = (
+ "HTTPException",
+ "InvalidTokenException",
+ "MessageRejectedError",
+ "TwitchioException",
+ "WebsocketConnectionException",
+)
+
+
+if TYPE_CHECKING:
+ from .http import Route
+ from .models import SentMessage
+ from .user import PartialUser
+
+
+class TwitchioException(Exception):
+ """Base exception for TwitchIO.
+
+ All custom TwitchIO exceptions inherit from this class.
+ """
+
+
+class HTTPException(TwitchioException):
+ """Exception raised when an HTTP request fails.
+
+ This exception can be raised anywhere the :class:`twitchio.Client` or :class:`twitchio.ext.commands.Bot`
+ is used to make a HTTP request to the Twitch API.
+
+ Attributes
+ ----------
+ route: :class:`twitchio.Route` | None
+ An optional :class:`twitchio.Route` supplied to this exception, which contains various information about the
+ request.
+ status: int
+ The HTTP response code received from Twitch. E.g. ``404`` or ``409``.
+ extra: dict[Literal["message"], str]
+ A dict with a single key named "message", which may contain additional information from Twitch
+ about why the request failed.
+ """
+
+ def __init__(
+ self,
+ msg: str = "",
+ /,
+ *,
+ route: Route | None = None,
+ status: int,
+ extra: str | dict[str, Any],
+ ) -> None:
+ self.route = route
+ self.status = status
+ self.extra = {"message": extra} if isinstance(extra, str) else extra
+
+ super().__init__(msg)
+
+
+class InvalidTokenException(HTTPException):
+ """Exception raised when an token can not be validated or refreshed.
+
+ This exception inherits from :exc:`~twitchio.HTTPException` and contains additional information.
+
+ .. warning::
+
+ This exception may contain sensitive information.
+
+ Attributes
+ ----------
+ token: str | None
+ The token which failed to be validated or refreshed. Could be ``None``.
+ refresh: str | None
+ The refresh token used to attempt refreshing the token. Could be ``None``.
+ route: :class:`twitchio.Route` | None
+ An optional :class:`twitchio.Route` supplied to this exception, which contains various information about the
+ request.
+ status: int
+ The HTTP response code received from Twitch. E.g. ``404`` or ``409``.
+ extra: dict[Literal["message"], str]
+ A dict with a single key named "message", which may contain additional information from Twitch
+ about why the request failed.
+ """
+
+ def __init__(
+ self,
+ msg: str = "",
+ /,
+ *,
+ token: str | None = None,
+ refresh: str | None = None,
+ type_: str,
+ original: HTTPException,
+ ) -> None:
+ self.token = token
+ self.refresh = refresh
+ self.invalid_type = type_
+
+ super().__init__(msg, route=original.route, status=original.status, extra=original.extra)
+
+
+class WebsocketConnectionException(TwitchioException):
+ """..."""
+
+
+class EventsubVerifyException(TwitchioException): ...
+
+
+class MessageRejectedError(TwitchioException):
+ """Exception raised when Twitch rejects a sent message. This is not the same as a :exc:`HTTPException` which is raised
+ when the request fails for a reason.
+
+ Attributes
+ ----------
+ channel: :class:`~twitchio.PartialUser`
+ The the channel the message was attempted to be sent to.
+ code: str | None
+ The drop code Twitch responded with.
+ message: str | None
+ The message Twitch responded with, with the reason why the message was rejected.
+ content: str
+ The content of the original message sent.
+ """
+
+ def __init__(self, msg: str, *, message: SentMessage, channel: PartialUser, content: str) -> None:
+ self.channel: PartialUser = channel
+ self.code: str | None = message.dropped_code
+ self.message: str | None = message.dropped_message
+ self.content: str = content
+ super().__init__(msg)
diff --git a/twitchio/ext/commands/__init__.py b/twitchio/ext/commands/__init__.py
index 40235e9f..95f04daa 100644
--- a/twitchio/ext/commands/__init__.py
+++ b/twitchio/ext/commands/__init__.py
@@ -1,29 +1,30 @@
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
-from .bot import Bot
-from .core import *
-from .errors import *
+from .bot import Bot as Bot
+from .components import *
+from .context import *
from .cooldowns import *
-from .meta import Cog
+from .core import *
+from .exceptions import *
diff --git a/twitchio/ext/commands/bot.py b/twitchio/ext/commands/bot.py
index 942839d4..422a7905 100644
--- a/twitchio/ext/commands/bot.py
+++ b/twitchio/ext/commands/bot.py
@@ -1,614 +1,699 @@
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
from __future__ import annotations
+
import asyncio
-import importlib
-import inspect
+import importlib.util
+import logging
import sys
-import traceback
import types
-import warnings
-from functools import partial
-from typing import Callable, Optional, Union, Coroutine, Dict, List, TYPE_CHECKING, Mapping, Awaitable
+from typing import TYPE_CHECKING, Any, TypeAlias, Unpack
from twitchio.client import Client
-from twitchio.http import TwitchHTTP
-from twitchio.websocket import WSConnection
-from .core import Command, Group, Context
-from .errors import *
-from .meta import Cog
-from .stringparser import StringParser
-from .utils import _CaseInsensitiveDict
+
+from ...utils import _is_submodule
+from .context import Context
+from .converters import _BaseConverter
+from .core import Command, CommandErrorPayload, Group, Mixin
+from .exceptions import *
+
if TYPE_CHECKING:
- from twitchio import Message
+ from collections.abc import Callable, Coroutine, Iterable, Mapping
+
+ from twitchio.eventsub.subscriptions import SubscriptionPayload
+ from twitchio.models.eventsub_ import ChatMessage
+ from twitchio.types_.eventsub import SubscriptionResponse
+ from twitchio.user import PartialUser
+
+ from .components import Component
+ from .types_ import BotOptions
+
+ PrefixT: TypeAlias = str | Iterable[str] | Callable[["Bot", "ChatMessage"], Coroutine[Any, Any, str | Iterable[str]]]
+
+
+logger: logging.Logger = logging.getLogger(__name__)
+
+
+class Bot(Mixin[None], Client):
+ """The TwitchIO ``commands.Bot`` class.
+
+ The Bot is an extension of and inherits from :class:`twitchio.Client` and comes with additonal powerful features for
+ creating and managing bots on Twitch.
+
+ Unlike :class:`twitchio.Client`, the :class:`~.Bot` class allows you to easily make use of built-in the commands ext.
+
+ The easiest way of creating and using a bot is via subclassing, some examples are provided below.
+
+ .. note::
+
+ Any examples contained in this class which use ``twitchio.Client`` can be changed to ``commands.Bot``.
+
+
+ Parameters
+ ----------
+ client_id: str
+ The client ID of the application you registered on the Twitch Developer Portal.
+ client_secret: str
+ The client secret of the application you registered on the Twitch Developer Portal.
+ This must be associated with the same ``client_id``.
+ bot_id: str
+ The User ID associated with the Bot Account.
+ Unlike on :class:`~twitchio.Client` this is a required argument on :class:`~.Bot`.
+ owner_id: str | None
+ An optional ``str`` which is the User ID associated with the owner of this bot. This should be set to your own user
+ accounts ID, but is not required. Defaults to ``None``.
+ prefix: str | Iterabale[str] | Coroutine[Any, Any, str | Iterable[str]]
+ The prefix(es) to listen to, to determine whether a message should be treated as a possible command.
+
+ This can be a ``str``, an iterable of ``str`` or a coroutine which returns either.
+
+ This is a required argument, common prefixes include: ``"!"`` or ``"?"``.
+
+ Example
+ -------
+
+ .. code:: python3
+
+ import asyncio
+ import logging
+
+ import twitchio
+ from twitchio import eventsub
+ from twitchio.ext import commands
+
+ LOGGER: logging.Logger = logging.getLogger("Bot")
+
+ class Bot(commands.Bot):
+
+ def __init__(self) -> None:
+ super().__init__(client_id="...", client_secret="...", bot_id="...", owner_id="...", prefix="!")
+ # Do some async setup, as an example we will load a component and subscribe to some events...
+ # Passing the bot to the component is completely optional...
+ async def setup_hook(self) -> None:
+
+ # Listen for messages on our channel...
+ # You need appropriate scopes, see the docs on authenticating for more info...
+ payload = eventsub.ChatMessageSubscription(broadcaster_user_id=self.owner_id, user_id=self.bot_id)
+ await self.subscribe_websocket(payload=payload)
+
+ await self.add_component(SimpleCommands(self))
+ LOGGER.info("Finished setup hook!")
+
+ class SimpleCommands(commands.Component):
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @commands.command()
+ async def hi(self, ctx: commands.Context) -> None:
+ '''Command which sends you a hello.'''
+ await ctx.reply(f"Hello {ctx.chatter}!")
+
+ @commands.command()
+ async def say(self, ctx: commands.Context, *, message: str) -> None:
+ '''Command which repeats what you say: !say I am an apple...'''
+ await ctx.send(message)
+
+ def main() -> None:
+ # Setup logging, this is optional, however a nice to have...
+ twitchio.utils.setup_logging(level=logging.INFO)
+
+ async def runner() -> None:
+ async with Bot() as bot:
+ await bot.start()
+
+ try:
+ asyncio.run(runner())
+ except KeyboardInterrupt:
+ LOGGER.warning("Shutting down due to Keyboard Interrupt...")
+
+ main()
+ """
-class Bot(Client):
def __init__(
self,
- token: str,
*,
- prefix: Union[str, list, tuple, set, Callable, Coroutine],
- client_secret: str = None,
- initial_channels: Union[list, tuple, Callable] = None,
- heartbeat: Optional[float] = 30.0,
- retain_cache: Optional[bool] = True,
- **kwargs,
- ):
+ client_id: str,
+ client_secret: str,
+ bot_id: str,
+ owner_id: str | None = None,
+ prefix: PrefixT,
+ **options: Unpack[BotOptions],
+ ) -> None:
super().__init__(
- token=token,
+ client_id=client_id,
client_secret=client_secret,
- initial_channels=initial_channels,
- heartbeat=heartbeat,
- retain_cache=retain_cache,
+ bot_id=bot_id,
+ **options,
)
- self._prefix = prefix
+ self._owner_id: str | None = owner_id
+ self._get_prefix: PrefixT = prefix
+ self._components: dict[str, Component] = {}
+ self._base_converter: _BaseConverter = _BaseConverter(self)
+ self.__modules: dict[str, types.ModuleType] = {}
- if kwargs.get("case_insensitive", False):
- self._commands: Union[dict, _CaseInsensitiveDict] = _CaseInsensitiveDict()
- self._command_aliases: Union[dict, _CaseInsensitiveDict] = _CaseInsensitiveDict()
- else:
- self._commands = {}
- self._command_aliases = {}
- self._modules: Dict[str, types.ModuleType] = {}
- self._cogs: Dict[str, Cog] = {}
- self._checks: List[Callable[[Context], Union[bool, Awaitable[bool]]]] = []
+ @property
+ def bot_id(self) -> str:
+ """Property returning the ID of the bot.
- self.__init__commands__()
+ You must ensure you set this via the keyword argument ``bot_id="..."`` in the constructor of this class.
- @classmethod
- def from_client_credentials(
- cls,
- client_id: str,
- client_secret: str,
- *,
- loop: asyncio.AbstractEventLoop = None,
- heartbeat: Optional[float] = 30.0,
- prefix: Union[str, list, tuple, set, Callable, Coroutine] = "!",
- ) -> Bot:
+ Returns
+ -------
+ str
+ The ``bot_id`` that was set.
"""
- creates a client application token from your client credentials.
-
- .. warning:
+ assert self._bot_id
+ return self._bot_id
- This method generates a token that is not suitable for logging in to IRC.
- This is not recommended for Bot objects, as it renders the commands system inoperable.
-
- .. note:
-
- This classmethod skips :meth:`~.__init__`
+ @property
+ def owner_id(self) -> str | None:
+ """Property returning the ID of the user who owns this bot.
- Parameters
- ------------
- client_id: :class:`str`
- Your application's Client ID.
- client_secret: :class:`str`
- An application Client Secret used to generate Access Tokens automatically.
- loop: Optional[:class:`asyncio.AbstractEventLoop`]
- The event loop the client will use to run.
- heartbeat: Optional[:class:`float`]
- The heartbeat interval. Defaults to 30.
- prefix: Union[:class:`str`, :class:`list`, :class:`tuple`, :class:`set`, Callable, Coroutine]
- The bots prefix. Defaults to "!".
+ This can be set via the keyword argument ``owner_id="..."`` in the constructor of this class.
Returns
- --------
- A new :class:`Bot` instance
+ -------
+ str | None
+ The owner ID that has been set. ``None`` if this has not been set.
"""
- warnings.warn(DeprecationWarning("from_client_credentials is not suitable for Bots."))
- self = cls.__new__(cls)
- self.loop = loop or asyncio.get_event_loop()
- self._http = TwitchHTTP(self, client_id=client_id, client_secret=client_secret)
- self._heartbeat = heartbeat
- self._connection = WSConnection(
- client=self,
- loop=self.loop,
- initial_channels=None,
- heartbeat=self._heartbeat,
- ) # The only reason we're even creating this is to avoid attribute errors
- self._events = {}
- self._waiting = []
- self._modules = {}
- self._prefix = prefix
- self._cogs = {}
- self._commands = {}
- self._command_aliases = {}
- self._checks = []
- self.registered_callbacks = {}
-
- return self
-
- def __init__commands__(self):
- commands = inspect.getmembers(self)
-
- for _, obj in commands:
- if not isinstance(obj, Command):
- continue
- obj._instance = self
+ return self._owner_id
+ async def close(self, **options: Any) -> None:
+ for module in tuple(self.__modules):
try:
- self.add_command(obj)
- except TwitchCommandError:
- traceback.print_exc()
- continue
-
- async def __get_prefixes__(self, message):
- ret = self._prefix
-
- if callable(self._prefix):
- if inspect.iscoroutinefunction(self._prefix):
- ret = await self._prefix(self, message)
- else:
- ret = self._prefix(self, message)
- if not isinstance(ret, (list, tuple, set, str)):
- raise TypeError(f"Prefix must be of either class not <{type(ret)}>")
- return ret
-
- async def get_prefix(self, message):
- # TODO Docs
- prefixes = await self.__get_prefixes__(message)
- message_content = message.content
- if "reply-parent-msg-id" in message.tags:
- message_content = " ".join(message.content.split(" ")[1:])
- else:
- message_content = message.content
- if not isinstance(prefixes, str):
- for prefix in prefixes:
- if message_content.startswith(prefix):
- return prefix
- elif message_content.startswith(prefixes):
- return prefixes
- else:
- return None
+ await self.unload_module(module)
+ except Exception as e:
+ logger.debug('Failed to unload module "%s" gracefully during close: %s.', module, e)
- def add_command(self, command: Command):
- """Method which registers a command for use by the bot.
+ for component in tuple(self._components):
+ try:
+ await self.remove_component(component)
+ except Exception as e:
+ logger.debug('Failed to remove component "%s" gracefully during close: %s.', component, e)
- Parameters
- ------------
- command: :class:`.Command`
- The command to register.
- """
- if not isinstance(command, Command):
- raise TypeError("Commands passed must be a subclass of Command.")
- elif command.name in self.commands:
- raise TwitchCommandError(
- f"Failed to load command <{command.name}>, a command with that name already exists."
- )
- elif not inspect.iscoroutinefunction(command._callback):
- raise TwitchCommandError(f"Failed to load command <{command.name}>. Commands must be coroutines.")
- self.commands[command.name] = command
-
- if not command.aliases:
- return
- for alias in command.aliases:
- if alias in self.commands:
- del self.commands[command.name]
- raise TwitchCommandError(
- f"Failed to load alias <{alias}> for command <{command.name}>, a command with that name/alias already exists.",
- )
- self._command_aliases[alias] = command.name
+ await super().close(**options)
+
+ def _cleanup_component(self, component: Component, /) -> None:
+ for command in component.__all_commands__.values():
+ self.remove_command(command.name)
+
+ for listeners in component.__all_listeners__.values():
+ for listener in listeners:
+ self.remove_listener(listener)
+
+ async def _add_component(self, component: Component, /) -> None:
+ for command in component.__all_commands__.values():
+ command._injected = component
+
+ if isinstance(command, Group):
+ for sub in command.walk_commands():
+ sub._injected = component
+
+ self.add_command(command)
+
+ for name, listeners in component.__all_listeners__.items():
+ for listener in listeners:
+ self.add_listener(listener, event=name)
+
+ await component.component_load()
+
+ async def add_component(self, component: Component, /) -> None:
+ """|coro|
+
+ Method to add a :class:`.commands.Component` to the bot.
- def get_command(self, name: str) -> Optional[Command]:
- """Method which retrieves a registered command.
+ All :class:`~.commands.Command` and :meth:`~.commands.Component.listener`'s in the component will be loaded alongside
+ the component.
+
+ If this method fails, including if :meth:`~.commands.Component.component_load` fails, everything will be rolled back
+ and cleaned up and a :exc:`.commands.ComponentLoadError` will be raised from the original exception.
Parameters
- ------------
- name: :class:`str`
- The name or alias of the command to retrieve.
+ ----------
+ component: :class:`~.commands.Component`
+ The component to add to the bot.
- Returns
- ---------
- Optional[:class:`.Command`]
+ Raises
+ ------
+ ComponentLoadError
+ The component failed to load.
"""
- name = self._command_aliases.get(name, name)
+ try:
+ await self._add_component(component)
+ except Exception as e:
+ self._cleanup_component(component)
+ raise ComponentLoadError from e
- return self._commands.get(name, None)
+ self._components[component.__component_name__] = component
- def remove_command(self, name: str):
- """
- Method which removes a registered command
+ async def remove_component(self, name: str, /) -> Component | None:
+ """|coro|
+
+ Method to remove a :class:`.commands.Component` from the bot.
+
+ All :class:`~.commands.Command` and :meth:`~.commands.Component.listener`'s in the component will be unloaded
+ alongside the component.
+
+ If this method fails when :meth:`~.commands.Component.component_teardown` fails, the component will still be unloaded
+ completely from the bot, with the exception being logged.
Parameters
- -----------
- name: :class:`str`
- the name or alias of the command to delete.
+ ----------
+ name: str
+ The name of the component to unload.
Returns
- --------
- None
-
- Raises
-------
- :class:`.CommandNotFound` The command was not found
+ Component | None
+ The component that was removed. ``None`` if the component was not found.
"""
- name = self._command_aliases.pop(name, name)
+ component: Component | None = self._components.pop(name, None)
+ if not component:
+ return component
- for alias in list(self._command_aliases.keys()):
- if self._command_aliases[alias] == name:
- del self._command_aliases[alias]
- try:
- del self._commands[name]
- except KeyError:
- raise CommandNotFound(f"The command '{name}` was not found")
+ self._cleanup_component(component)
- def get_cog(self, name: str) -> Optional[Cog]:
- """Retrieve a Cog from the bots loaded Cogs.
+ try:
+ await component.component_teardown()
+ except Exception as e:
+ msg = f"Ignoring exception in {component.__class__.__qualname__}.component_teardown: {e}\n"
+ logger.error(msg, exc_info=e)
- Could be None if the Cog was not found.
+ return component
- Returns
- ---------
- Optional[:class:`.Cog`]
+ def get_component(self, name: str, /) -> Component | None:
"""
- return self.cogs.get(name, None)
-
- async def get_context(self, message, *, cls=None):
- """Get a Context object from a message.
+ Retrieve a Component from the bots loaded Component.
+ This will return `None` if the Component was not found.
Parameters
----------
- message: :class:`.Message`
- The message object to get context for.
- cls
- The class to return. Defaults to Context. Its constructor must take message, prefix, valid, and bot
- as arguments.
+ name: str
+ The name of the Component.
Returns
- ---------
- An instance of cls.
-
- Raises
- ---------
- :class:`.CommandNotFound` No valid command was passed
+ -------
+ Component | None
"""
- if not cls:
- cls = Context
- prefix = await self.get_prefix(message)
- if not prefix:
- return cls(message=message, prefix=prefix, valid=False, bot=self)
- content = message.content
- if "reply-parent-msg-id" in message.tags: # Remove @username from reply message
- content = content.split(" ", 1)[1]
- content = content[len(prefix) : :].lstrip() # Strip prefix and remainder whitespace
- view = StringParser()
- parsed = view.process_string(content) # Return the string as a dict view
+ return self._components.get(name)
- try:
- command_ = parsed.pop(0)
- except KeyError:
- context = cls(message=message, bot=self, prefix=prefix, command=None, valid=False, view=view)
- error = CommandNotFound("No valid command was passed.", "")
+ def get_context(self, message: ChatMessage, *, cls: Any = None) -> Any:
+ cls = cls or Context
+ return cls(message, bot=self)
+
+ async def _process_commands(self, message: ChatMessage) -> None:
+ ctx: Context = self.get_context(message)
+ await self.invoke(ctx)
- self.run_event("command_error", context, error)
- return context
+ async def process_commands(self, message: ChatMessage) -> None:
+ await self._process_commands(message)
+
+ async def invoke(self, ctx: Context) -> None:
try:
- command_ = self._command_aliases[command_]
- except KeyError:
- pass
- if command_ in self.commands:
- command_ = self.commands[command_]
- else:
- context = cls(message=message, bot=self, prefix=prefix, command=None, valid=False, view=view)
- error = CommandNotFound(f'No command "{command_}" was found.', command_)
+ await ctx.invoke()
+ except CommandError as e:
+ payload = CommandErrorPayload(context=ctx, exception=e)
+ self.dispatch("command_error", payload=payload)
- self.run_event("command_error", context, error)
- return context
- context = cls(message=message, bot=self, prefix=prefix, command=command_, valid=True, view=view)
+ async def event_message(self, payload: ChatMessage) -> None:
+ if payload.chatter.id == self.bot_id:
+ return
- return context
+ if payload.source_broadcaster is not None:
+ return
- async def handle_commands(self, message):
- """|coro|
+ await self.process_commands(payload)
- This method handles commands sent from chat and invokes them.
+ async def event_command_error(self, payload: CommandErrorPayload) -> None:
+ """An event called when an error occurs during command invocation.
- By default, this coroutine is called within the :func:`Bot.event_message` event.
- If you choose to override :func:`Bot.event_message` then you need to invoke this coroutine in order to handle commands.
+ By default this event logs the exception raised.
+
+ You can override this method, however you should take care to log unhandled exceptions.
Parameters
----------
- message: :class:`.Message`
- The message object to get content of and context for.
-
+ payload: :class:`.commands.CommandErrorPayload`
+ The payload associated with this event.
"""
- context = await self.get_context(message)
- await self.invoke(context)
-
- async def invoke(self, context):
- # TODO Docs
- if not context.prefix or not context.is_valid:
+ command: Command[Any, ...] | None = payload.context.command
+ if command and command.has_error and payload.context.error_dispatched:
return
- self.run_event("command_invoke", context)
- await context.command(context)
- def load_module(self, name: str) -> None:
- """Method which loads a module and its cogs.
+ msg = f'Ignoring exception in command "{payload.context.command}":\n'
+ logger.error(msg, exc_info=payload.exception)
- Parameters
- ------------
- name: str
- The name of the module to load in dot.path format.
- """
- if name in self._modules:
- raise ValueError(f"Module <{name}> is already loaded")
- module = importlib.import_module(name)
+ async def before_invoke(self, ctx: Context) -> None:
+ """A pre invoke hook for all commands that have been added to the bot.
- if hasattr(module, "prepare"):
- module.prepare(self) # type: ignore
- else:
- del module
- del sys.modules[name]
- raise ImportError(f"Module <{name}> is missing a prepare method")
- self._modules[name] = module
+ Commands from :class:`~.commands.Component`'s are included, however if you wish to control them separately,
+ see: :meth:`~.commands.Component.component_before_invoke`.
+
+ The pre-invoke hook will be called directly before a valid command is scheduled to run. If this coroutine errors,
+ a :exc:`~.commands.CommandHookError` will be raised from the original error.
+
+ Useful for setting up any state like database connections or http clients for command invocation.
+
+ The order of calls with the pre-invoke hooks is:
+
+ - :meth:`.commands.Bot.before_invoke`
+
+ - :meth:`.commands.Component.component_before_invoke`
+
+ - Any ``before_invoke`` hook added specifically to the :class:`~.commands.Command`.
+
+
+ .. note::
- def unload_module(self, name: str) -> None:
- """Method which unloads a module and its cogs.
+ This hook only runs after successfully parsing arguments and passing all guards associated with the
+ command, component (if applicable) and bot.
Parameters
----------
- name: str
- The name of the module to unload in dot.path format.
+ ctx: :class:`.commands.Context`
+ The context associated with command invocation, before being passed to the command.
"""
- if name not in self._modules:
- raise ValueError(f"Module <{name}> is not loaded")
- module = self._modules.pop(name)
- if hasattr(module, "breakdown"):
- try:
- module.breakdown(self) # type: ignore
- except:
- pass
- to_delete = [cog_name for cog_name, cog in self._cogs.items() if cog.__module__ == module.__name__]
- for name in to_delete:
- self.remove_cog(name)
- to_delete = [name for name, cmd in self._commands.items() if cmd._callback.__module__ == module.__name__]
- for name in to_delete:
- self.remove_command(name)
- to_delete = [
- x
- for y in self._events.items()
- for x in y[1]
- if isinstance(x, partial) and x.func.__module__ == module.__name__
- ]
- for event in to_delete:
- self.remove_event(event)
- for m in list(sys.modules.keys()):
- if m == module.__name__ or m.startswith(module.__name__ + "."):
- del sys.modules[m]
-
- def reload_module(self, name: str):
- """Method which reloads a module and its cogs.
+ async def after_invoke(self, ctx: Context) -> None:
+ """A post invoke hook for all commands that have been added to the bot.
- Parameters
- ----------
- name: str
- The name of the module to unload in dot.path format.
+ Commands from :class:`~.commands.Component`'s are included, however if you wish to control them separately,
+ see: :meth:`~.commands.Component.component_after_invoke`.
+ The post-invoke hook will be called after a valid command has been invoked. If this coroutine errors,
+ a :exc:`~.commands.CommandHookError` will be raised from the original error.
- .. note::
+ Useful for cleaning up any state like database connections or http clients.
- This is roughly equivalent to `bot.unload_module(...)` then `bot.load_module(...)`.
- """
- if name not in self._modules:
- raise ValueError(f"Module <{name}> is not loaded")
- module = self._modules[name]
+ The order of calls with the post-invoke hooks is:
- modules = {
- name: m
- for name, m in sys.modules.items()
- if name == module.__name__ or name.startswith(module.__name__ + ".")
- }
+ - :meth:`.commands.Bot.after_invoke`
- try:
- self.unload_module(name)
- self.load_module(name)
- except Exception as e:
- sys.modules.update(modules)
- module.prepare(self) # type: ignore
- self._modules[name] = module
- raise
+ - :meth:`.commands.Component.component_after_invoke`
- def add_cog(self, cog: Cog):
- """Method which adds a cog to the bot.
+ - Any ``after_invoke`` hook added specifically to the :class:`~.commands.Command`.
- Parameters
- ----------
- cog: :class:`Cog`
- The cog instance to add to the bot.
+ .. note::
- .. warning::
+ This hook is always called even when the :class:`~.commands.Command` fails to invoke but similar to
+ :meth:`.before_invoke` only if parsing arguments and guards are successfully completed.
- This must be an instance of :class:`Cog`.
- """
- if not isinstance(cog, Cog):
- raise InvalidCog('Cogs must derive from "commands.Cog".')
- if cog.name in self._cogs:
- raise InvalidCog(f'Cog "{cog.name}" has already been loaded.')
- cog._load_methods(self)
- self._cogs[cog.name] = cog
-
- def remove_cog(self, cog_name: str):
- """Method which removes a cog from the bot.
Parameters
----------
- cog_name: str
- The name of the cog to remove.
+ ctx: :class:`.commands.Context`
+ The context associated with command invocation, after being passed through the command.
"""
- if cog_name not in self._cogs:
- raise InvalidCog(f"Cog '{cog_name}' not found")
- cog = self._cogs.pop(cog_name)
- cog._unload_methods(self)
- async def global_before_invoke(self, ctx):
+ async def global_guard(self, ctx: Context, /) -> bool:
"""|coro|
- Method which is called before any command is about to be invoked.
+ A global guard applied to all commmands added to the bot.
- This method is useful for setting up things before command invocation. E.g Database connections or
- retrieving tokens for use in the command.
+ This coroutine function should take in one parameter :class:`~.commands.Context` the context surrounding
+ command invocation, and return a bool indicating whether a command should be allowed to run.
- Parameters
- ------------
- ctx:
- The context used for command invocation.
+ If this function returns ``False``, the chatter will not be able to invoke the command and an error will be
+ raised. If this function returns ``True`` the chatter will be able to invoke the command,
+ assuming all the other guards also pass their predicate checks.
- Examples
- ----------
- .. code:: py
+ See: :func:`~.commands.guard` for more information on guards, what they do and how to use them.
+
+ .. note::
- async def global_before_invoke(self, ctx):
- # Make a database query for example to retrieve a specific token.
- token = db_query()
+ This is the first guard to run, and is applied to every command.
- ctx.token = token
+ .. important::
- async def my_command(self, ctx):
- data = await self.create_clip(ctx.token, ...)
+ Unlike command specific guards or :meth:`.commands.Component.guard`, this function must
+ be always be a coroutine.
- Note
+
+ This coroutine is intended to be overriden when needed and by default always returns ``True``.
+
+ Parameters
+ ----------
+ ctx: commands.Context
+ The context associated with command invocation.
+
+ Raises
------
- The global_before_invoke is called before any other command specific hooks.
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
"""
- pass
+ return True
+
+ async def subscribe_webhook(
+ self,
+ payload: SubscriptionPayload,
+ *,
+ as_bot: bool = True,
+ token_for: str | PartialUser | None,
+ callback_url: str | None = None,
+ eventsub_secret: str | None = None,
+ ) -> SubscriptionResponse | None:
+ return await super().subscribe_webhook(
+ payload=payload,
+ as_bot=as_bot,
+ token_for=token_for,
+ callback_url=callback_url,
+ eventsub_secret=eventsub_secret,
+ )
+
+ async def subscribe_websocket(
+ self,
+ payload: SubscriptionPayload,
+ *,
+ as_bot: bool = True,
+ token_for: str | PartialUser | None = None,
+ socket_id: str | None = None,
+ ) -> SubscriptionResponse | None:
+ return await super().subscribe_websocket(payload=payload, as_bot=as_bot, token_for=token_for, socket_id=socket_id)
+
+ def _get_module_name(self, name: str, package: str | None) -> str:
+ try:
+ return importlib.util.resolve_name(name, package)
+ except ImportError as e:
+ raise ModuleNotFoundError(f'The module "{name}" was not found.') from e
+
+ async def _remove_module_remnants(self, name: str) -> None:
+ for component_name, component in self._components.copy().items():
+ if component.__module__ == name or component.__module__.startswith(f"{name}."):
+ await self.remove_component(component_name)
+
+ async def _module_finalizers(self, name: str, module: types.ModuleType) -> None:
+ try:
+ func = getattr(module, "teardown")
+ except AttributeError:
+ pass
+ else:
+ try:
+ await func(self)
+ except Exception:
+ pass
+ finally:
+ self.__modules.pop(name, None)
+ sys.modules.pop(name, None)
- async def global_after_invoke(self, ctx: Context) -> None:
+ name = module.__name__
+ for m in list(sys.modules.keys()):
+ if _is_submodule(name, m):
+ del sys.modules[m]
+
+ async def load_module(self, name: str, *, package: str | None = None) -> None:
"""|coro|
- Method which is called after any command is invoked regardless if it failed or not.
+ Loads a module.
+
+ A module is a python module that contains commands, cogs, or listeners.
+
+ A module must have a global coroutine, ``setup`` defined as the entry point on what to do when the module is loaded.
+ The coroutine takes a single argument, the ``bot``.
- This method is useful for cleaning up things after command invocation. E.g Database connections.
+ .. versionchanged:: 3.0
+ This method is now a :term:`coroutine`.
Parameters
- ------------
- ctx:
- The context used for command invocation.
+ ----------
+ name: str
+ The module to load. It must be dot separated like regular Python imports accessing a sub-module.
+ e.g. ``foo.bar`` if you want to import ``foo/bar.py``.
+ package: str | None
+ The package name to resolve relative imports with.
+ This is required when loading an extension using a relative path.
+ e.g. ``.foo.bar``. Defaults to ``None``.
- Note
+ Raises
------
- The global_after_invoke is called only after the command successfully invokes.
+ ModuleAlreadyLoadedError
+ The module is already loaded.
+ ModuleNotFoundError
+ The module could not be imported.
+ Also raised if module could not be resolved using the `package` parameter.
+ ModuleLoadFailure
+ There was an error loading the module.
+ NoEntryPointError
+ The module does not have a setup coroutine.
+ TypeError
+ The module's setup function is not a coroutine.
"""
- pass
- @property
- def commands(self):
- """The currently loaded commands."""
- return self._commands
+ name = self._get_module_name(name, package)
- @property
- def cogs(self) -> Mapping[str, Cog]:
- """The currently loaded cogs."""
- return self._cogs
+ if name in self.__modules:
+ raise ModuleAlreadyLoadedError(f"The module {name} has already been loaded.")
- async def event_command_error(self, context: Context, error: Exception) -> None:
- """|coro|
+ spec = importlib.util.find_spec(name)
+ if spec is None:
+ raise ModuleNotFoundError(name)
- Event called when an error occurs during command invocation.
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
- Parameters
- ------------
- context: :class:`.Context`
- The command context.
- error: :class:`.Exception`
- The exception raised while trying to invoke the command.
- """
- print(f"Ignoring exception in command: {error}:", file=sys.stderr)
- traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+ try:
+ spec.loader.exec_module(module) # type: ignore
+ except Exception as e:
+ del sys.modules[name]
+ raise ModuleLoadFailure(name, e) from e
- async def event_message(self, message: Message) -> None:
+ try:
+ entry = getattr(module, "setup")
+ except AttributeError as exc:
+ del sys.modules[name]
+ raise NoEntryPointError(f'The module "{module}" has no setup coroutine.') from exc
+
+ if not asyncio.iscoroutinefunction(entry):
+ del sys.modules[name]
+ raise TypeError(f'The module "{module}"\'s setup function is not a coroutine.')
+
+ try:
+ await entry(self)
+ except Exception as e:
+ del sys.modules[name]
+ await self._remove_module_remnants(module.__name__)
+ raise ModuleLoadFailure(name, e) from e
+
+ self.__modules[name] = module
+
+ async def unload_module(self, name: str, *, package: str | None = None) -> None:
"""|coro|
- Event called when a PRIVMSG is received from Twitch.
+ Unloads a module.
- Parameters
- ------------
- message: :class:`.Message`
- Message object containing relevant information.
- """
- if message.echo:
- return
- await self.handle_commands(message)
+ When the module is unloaded, all commands, listeners and components are removed from the bot, and the module is un-imported.
- def command(
- self, *, name: str = None, aliases: Union[list, tuple] = None, cls=Command, no_global_checks=False
- ) -> Callable[[Callable], Command]:
- """Decorator which registers a command with the bot.
+ You can add an optional global coroutine of ``teardown`` to the module to do miscellaneous clean-up if necessary.
+ This also takes a single paramter of the ``bot``, similar to ``setup``.
- Commands must be a coroutine.
+ .. versionchanged:: 3.0
+ This method is now a :term:`coroutine`.
Parameters
- ------------
- name: str [Optional]
- The name of the command. By default if this is not supplied, the function name will be used.
- aliases: Optional[Union[list, tuple]]
- The command aliases. This must be a list or tuple.
- cls: class [Optional]
- The custom command class to override the default class. This must be similar to :class:`.Command`.
- no_global_checks : Optional[bool]
- Whether or not the command should abide by global checks. Defaults to False, which checks global checks.
+ ----------
+ name: str
+ The module to unload. It must be dot separated like regular Python imports accessing a sub-module.
+ e.g. ``foo.bar`` if you want to import ``foo/bar.py``.
+ package: str | None
+ The package name to resolve relative imports with.
+ This is required when unloading an extension using a relative path.
+ e.g. ``.foo.bar``. Defaults to ``None``.
Raises
- --------
- TypeError
- cls is not type class.
+ ------
+ ModuleNotLoaded
+ The module was not loaded.
"""
- if not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
+ name = self._get_module_name(name, package)
+ module = self.__modules.get(name)
- def decorator(func: Callable):
- cmd_name = name or func.__name__
+ if module is None:
+ raise ModuleNotLoadedError(name)
- cmd = cls(name=cmd_name, func=func, aliases=aliases, instance=None, no_global_checks=no_global_checks)
- self.add_command(cmd)
+ await self._remove_module_remnants(module.__name__)
+ await self._module_finalizers(name, module)
- return cmd
+ async def reload_module(self, name: str, *, package: str | None = None) -> None:
+ """|coro|
- return decorator
+ Atomically reloads a module.
- def group(
- self, *, name: str = None, aliases: Union[list, tuple] = None, cls=Group, no_global_checks=False
- ) -> Callable[[Callable], Group]:
- if not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
+ This attempts to unload and then load the module again, in an atomic way.
+ If an operation fails mid reload then the bot will revert back to the prior working state.
- def decorator(func: Callable):
- cmd_name = name or func.__name__
+ .. versionchanged:: 3.0
+ This method is now a :term:`coroutine`.
- cmd = cls(name=cmd_name, func=func, aliases=aliases, instance=None, no_global_checks=no_global_checks)
- self.add_command(cmd)
+ Parameters
+ ----------
+ name: str
+ The module to unload. It must be dot separated like regular Python imports accessing a sub-module.
+ e.g. ``foo.bar`` if you want to import ``foo/bar.py``.
+ package: str | None
+ The package name to resolve relative imports with.
+ This is required when unloading an extension using a relative path.
+ e.g. ``.foo.bar``. Defaults to ``None``.
+
+ Raises
+ ------
+ ModuleNotLoaded
+ The module was not loaded.
+ ModuleNotFoundError
+ The module could not be imported.
+ Also raised if module could not be resolved using the `package` parameter.
+ ModuleLoadFailure
+ There was an error loading the module.
+ NoEntryPointError
+ The module does not have a setup coroutine.
+ TypeError
+ The module's setup function is not a coroutine.
+ """
- return cmd
+ name = self._get_module_name(name, package)
+ module = self.__modules.get(name)
- return decorator
+ if module is None:
+ raise ModuleNotLoadedError(name)
- def check(self, func: Callable[[Context], bool]) -> Callable:
- if func in self._checks:
- raise ValueError("The function is already registered as a bot check")
- self._checks.append(func)
- return func
+ modules = {name: module for name, module in sys.modules.items() if _is_submodule(module.__name__, name)}
+
+ try:
+ await self._remove_module_remnants(module.__name__)
+ await self._module_finalizers(name, module)
+ await self.load_module(name)
+ except Exception as e:
+ await module.setup(self)
+ self.__modules[name] = module
+ sys.modules.update(modules)
+ raise e
+
+ @property
+ def modules(self) -> Mapping[str, types.ModuleType]:
+ """Mapping[:class:`str`, :class:`py:types.ModuleType`]: A read-only mapping of extension name to extension."""
+ return types.MappingProxyType(self.__modules)
diff --git a/twitchio/ext/commands/builtin_converter.py b/twitchio/ext/commands/builtin_converter.py
deleted file mode 100644
index 773c9812..00000000
--- a/twitchio/ext/commands/builtin_converter.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from __future__ import annotations
-import re
-from typing import TYPE_CHECKING
-
-from twitchio import User, PartialUser, Chatter, PartialChatter, Channel, Clip
-from .errors import BadArgument
-
-if TYPE_CHECKING:
- from .core import Context
-
-
-__all__ = (
- "convert_Chatter",
- "convert_Clip",
- "convert_Channel",
- "convert_PartialChatter",
- "convert_PartialUser",
- "convert_User",
-)
-
-
-async def convert_Chatter(ctx: Context, arg: str) -> Chatter:
- """
- Converts the argument into a chatter in the chat. If the chatter is not found, BadArgument is raised.
- """
- arg = arg.lstrip("@")
- resp = [x for x in filter(lambda c: c.name == arg, ctx.chatters or tuple())]
- if not resp:
- raise BadArgument(f"The user '{arg}' was not found in {ctx.channel.name}'s chat.")
-
- return resp[0]
-
-
-async def convert_PartialChatter(ctx: Context, arg: str) -> PartialChatter:
- """
- Converts the argument into a chatter in the chat. As opposed to Chatter converter, this will return a PartialChatter regardless of the cache state.
- """
- return PartialChatter(ctx._ws, name=arg.lstrip("@"), channel=ctx.channel, message=None)
-
-
-async def convert_Clip(ctx: Context, arg: str) -> Clip:
- finder = re.search(r"(https://clips.twitch.tv/)?(?P.*)", arg)
- if not finder:
- raise RuntimeError(
- "regex failed to match"
- ) # this should never ever raise, but its here to make type checkers happy
-
- slug = finder.group("slug")
- clips = await ctx.bot.fetch_clips([slug])
- if not clips:
- raise BadArgument(f"Clip '{slug}' was not found")
-
- return clips[0]
-
-
-async def convert_User(ctx: Context, arg: str) -> User:
- """
- Similar to convert_Chatter, but fetches from the twitch API instead,
- returning a :class:`twitchio.User` instead of a :class:`twitchio.Chatter`.
- To use this, you most have a valid client id and API token or client secret
- """
- arg = arg.lstrip("@")
- user = await ctx.bot.fetch_users(names=[arg])
- if not user:
- raise BadArgument(f"User '{arg}' was not found.")
- return user[0]
-
-
-async def convert_PartialUser(ctx: Context, arg: str) -> User:
- """
- This is simply a shorthand to :ref:`~convert_User`, as fetching from the api will return a full user model
- """
- return await convert_User(ctx, arg)
-
-
-async def convert_Channel(ctx: Context, arg: str) -> Channel:
- if arg not in ctx.bot._connection._cache:
- raise BadArgument(f"Not connected to channel '{arg}'")
-
- return ctx.bot.get_channel(arg)
-
-
-_mapping = {
- User: convert_User,
- PartialUser: convert_PartialUser,
- Channel: convert_Channel,
- Chatter: convert_Chatter,
- PartialChatter: convert_PartialChatter,
- Clip: convert_Clip,
-}
diff --git a/twitchio/ext/commands/components.py b/twitchio/ext/commands/components.py
new file mode 100644
index 00000000..0690c344
--- /dev/null
+++ b/twitchio/ext/commands/components.py
@@ -0,0 +1,420 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+from functools import partial
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeAlias, Unpack
+
+from .core import Command, CommandErrorPayload
+
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from .context import Context
+ from .types_ import ComponentOptions
+
+
+__all__ = ("Component",)
+
+
+CoroC: TypeAlias = Coroutine[Any, Any, bool]
+
+
+class _MetaComponent:
+ __component_name__: str
+ __component_extras__: dict[Any, Any]
+ __component_specials__: ClassVar[list[str]] = []
+ __all_commands__: dict[str, Command[Any, ...]]
+ __all_listeners__: dict[str, list[Callable[..., Coroutine[Any, Any, None]]]]
+ __all_guards__: list[Callable[..., bool] | Callable[..., CoroC]]
+
+ @classmethod
+ def _component_special(cls, obj: Any) -> Any:
+ setattr(obj, "__component_special__", True)
+ cls.__component_specials__.append(obj.__name__)
+
+ return obj
+
+ def __init_subclass__(cls, **kwargs: Unpack[ComponentOptions]) -> None:
+ name: str | None = kwargs.get("name")
+ if name:
+ cls.__component_name__ = name
+
+ cls.__component_extras__ = kwargs.get("extras", {})
+
+ def __new__(cls, *args: Any, **Kwargs: Any) -> Self:
+ self: Self = super().__new__(cls)
+
+ if not hasattr(self, "__component_name__"):
+ self.__component_name__ = cls.__qualname__
+
+ commands: dict[str, Command[Any, ...]] = {}
+ listeners: dict[str, list[Callable[..., Coroutine[Any, Any, None]]]] = {}
+ guards: list[Callable[..., bool] | Callable[..., CoroC]] = []
+
+ no_special: str = 'Commands, listeners and guards must not start with special name "component_" in components:'
+ no_over: str = 'The special method "{}" can not be overriden in components.'
+
+ for base in reversed(cls.__mro__):
+ for name, member in base.__dict__.items():
+ if name in self.__component_specials__ and not hasattr(member, "__component_special__"):
+ raise TypeError(no_over.format(name))
+
+ if isinstance(member, Command):
+ if name.startswith("component_"):
+ raise TypeError(f'{no_special} "{member._callback.__qualname__}" is invalid.') # type: ignore
+
+ if not member.extras:
+ member._extras = self.__component_extras__
+
+ if not member.parent: # type: ignore
+ commands[name] = member
+
+ elif hasattr(member, "__listener_name__"):
+ if name.startswith("component_"):
+ raise TypeError(f'{no_special} "{member.__qualname__}" is invalid.')
+
+ # Need to inject the component into the listener...
+ injected = partial(member, self)
+
+ try:
+ listeners[member.__listener_name__].append(injected)
+ except KeyError:
+ listeners[member.__listener_name__] = [injected]
+
+ elif hasattr(member, "__component_guard__"):
+ if not member.__component_guard__:
+ continue
+
+ if name.startswith("component_"):
+ raise TypeError(f'{no_special} "{member.__qualname__}" is invalid.')
+
+ guards.append(member)
+
+ cls.__all_commands__ = commands
+ cls.__all_listeners__ = listeners
+ cls.__all_guards__ = guards
+
+ return self
+
+
+class Component(_MetaComponent):
+ """TwitchIO Component class.
+
+ Components are a powerful class used to help organize and manage commands, events, guards and errors.
+
+ This class inherits from a special metaclass which implements logic to help manage other parts of the TwitchIO
+ commands extension together.
+
+ The Component must be added to your bot via :meth:`.commands.Bot.add_component`. After this class has been added, all
+ commands and event listeners contained within the component will also be added to your bot.
+
+ You can remove this Component and all the commands and event listeners associated with it via
+ :meth:`.commands.Bot.remove_component`.
+
+ There are two built-in methods of components that aid in doing any setup or teardown, when they are added and removed
+ respectfully.
+
+ - :meth:`~.component_load`
+
+ - :meth:`~.component_teardown`
+
+
+ Below are some special methods of Components which are designed to be overriden when needed:
+
+ - :meth:`~.component_load`
+
+ - :meth:`~.component_teardown`
+
+ - :meth:`~.component_command_error`
+
+ - :meth:`~.component_before_invoke`
+
+ - :meth:`~.component_after_invoke`
+
+
+ Components also implement some special decorators which can only be used inside of Components. The decorators are
+ class method decorators and won't need an instance of a Component to use.
+
+ - :meth:`~.listener`
+
+ - :meth:`~.guard`
+
+
+ Commands can beed added to Components with their respected decorators, and don't need to be added to the bot, as they
+ will be added when you add the component.
+
+ - ``@commands.command()``
+
+ - ``@commands.group()``
+
+
+ .. note::
+
+ This version of TwitchIO has not yet implemented the ``modules`` implementation of ``commands.ext``.
+ This part of ``commands.ext`` will allow you to easily load and unload separate python files that could contain
+ components.
+
+ .. important::
+
+ Due to the implementation of Components, you shouldn't make a call to ``super().__init__()`` if you implement an
+ ``__init__`` on this component.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ class Bot(commands.Bot):
+ # Do your required __init__ etc first...
+
+ # You can use setup_hook to add components...
+ async def setup_hook(self) -> None:
+ await self.add_component(MyComponent())
+
+
+ class MyComponent(commands.Component):
+
+ # Some simple commands...
+ @commands.command()
+ async def hi(self, ctx: commands.Command) -> None:
+ await ctx.send(f"Hello {ctx.chatter.mention}!")
+
+ @commands.command()
+ async def apple(self, ctx: commands.Command, *, count: int) -> None:
+ await ctx.send(f"You have {count} apples?!")
+
+ # An example of using an event listener in a component...
+ @commands.Component.listener()
+ async def event_message(self, message: twitchio.ChatMessage) -> None:
+ print(f"Received Message in component: {message.content}")
+
+ # An example of a before invoke hook that is executed directly before any command in this component...
+ async def component_before_invoke(self, ctx: commands.Command) -> None:
+ print(f"Processing command in component '{self.name}'.")
+ """
+
+ @property
+ def name(self) -> str:
+ """Property returning the name of this component, this is either the qualified name of the class or
+ the custom provided name if set.
+ """
+ return self.__component_name__
+
+ async def component_command_error(self, payload: CommandErrorPayload) -> bool | None:
+ """Event called when an error occurs in a command in this Component.
+
+ Similar to :meth:`~.commands.Bot.event_command_error` except only catches errors from commands within this Component.
+
+ This method is intended to be overwritten, by default it does nothing.
+
+ .. note::
+
+ Explicitly returning ``False`` in this function will stop it being dispatched to any other error handler.
+ """
+
+ async def component_load(self) -> None:
+ """Hook called when the component is about to be loaded into the bot.
+
+ You should use this hook to do any async setup required when loading a component.
+ See: :meth:`.component_teardown` for a hook called when a Component is unloaded from the bot.
+
+ This method is intended to be overwritten, by default it does nothing.
+
+ .. important::
+
+ If this method raises or fails, the Component will **NOT** be loaded. Instead it will be cleaned up and removed
+ and the error will propagate.
+ """
+
+ async def component_teardown(self) -> None:
+ """Hook called when the component is about to be unloaded from the bot.
+
+ You should use this hook to do any async teardown/cleanup required on the component.
+ See: :meth:`.component_load` for a hook called when a Component is loaded into the bot.
+
+ This method is intended to be overwritten, by default it does nothing.
+ """
+
+ async def component_before_invoke(self, ctx: Context) -> None:
+ """Hook called before a :class:`~.commands.Command` in this Component is invoked.
+
+ Similar to :meth:`~.commands.Bot.before_invoke` but only applies to commands in this Component.
+ """
+
+ async def component_after_invoke(self, ctx: Context) -> None:
+ """Hook called after a :class:`~.commands.Command` has successfully invoked in this Component.
+
+ Similar to :meth:`~.commands.Bot.after_invoke` but only applies to commands in this Component.
+ """
+
+ @_MetaComponent._component_special
+ def extras(self) -> MappingProxyType[Any, Any]:
+ """Property returning a :class:`types.MappingProxyType` of the extras applied to every command in this Component.
+
+ See: :attr:`~.commands.Command.extras` for more information on :class:`~.commands.Command` extras.
+ """
+ return MappingProxyType(self.__component_extras__)
+
+ @_MetaComponent._component_special
+ def guards(self) -> list[Callable[..., bool] | Callable[..., CoroC]]:
+ """Property returning the guards applied to every command in this Component.
+
+ See: :func:`.commands.guard` for more information on guards and how to use them.
+
+ See: :meth:`.guard` for a way to apply guards to every command in this Component.
+ """
+ return self.__all_guards__
+
+ @classmethod
+ def listener(cls, name: str | None = None) -> Any:
+ """|deco|
+
+ A decorator which adds an event listener similar to :meth:`~.commands.Bot.listener` but contained within this
+ component.
+
+ Event listeners in components can listen to any dispatched event, and don't interfere with their base implementation.
+ See: :meth:`~.commands.Bot.listener` for more information on event listeners.
+
+ By default, listeners use the name of the function wrapped for the event name. This can be changed by passing the
+ name parameter.
+
+ .. note::
+
+ You can have multiple of the same event listener per component, see below for an example.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ # By default if no name parameter is passed, the name of the event listened to is the same as the function...
+
+ class MyComponent(commands.Component):
+
+ @commands.Component.listener()
+ async def event_message(self, payload: twitchio.ChatMessage) -> None:
+ ...
+
+ .. code:: python3
+
+ # You can listen to two or more of the same event in a single component...
+ # The name parameter should have the "event_" prefix removed...
+
+ class MyComponent(commands.Component):
+
+ @commands.Component.listener("message")
+ async def event_message_one(self, payload: twitchio.ChatMessage) -> None:
+ ...
+
+ @commands.Component.listener("message")
+ async def event_message_two(self, payload: twitchio.ChatMessage) -> None:
+ ...
+
+ Parameters
+ ----------
+ name: str
+ The name of the event to listen to, E.g. ``"event_message"`` or simply ``"message"``.
+ """
+
+ def wrapper(func: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., Coroutine[Any, Any, None]]:
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError(f'Component listener func "{func.__qualname__}" must be a coroutine function.')
+
+ name_ = name or func.__name__
+ qual = f"event_{name_.removeprefix('event_')}"
+
+ setattr(func, "__listener_name__", qual)
+ return func
+
+ return wrapper
+
+ @classmethod
+ def guard(cls) -> Any:
+ """|deco|
+
+ A decorator which wraps a standard function *or* coroutine function which should
+ return either ``True`` or ``False``, and applies a guard to every :class:`~.commands.Command` in this component.
+
+ The wrapped function should take in one parameter :class:`~.commands.Context` the context surrounding
+ command invocation, and return a bool indicating whether a command should be allowed to run.
+
+ If the wrapped function returns ``False``, the chatter will not be able to invoke the command and an error will be
+ raised. If the wrapped function returns ``True`` the chatter will be able to invoke the command,
+ assuming all the other guards also pass their predicate checks.
+
+ See: :func:`~.commands.guard` for more information on guards, what they do and how to use them.
+
+ See: :meth:`~.commands.Bot.global_guard` for a global guard, applied to every command the bot has added.
+
+ Example
+ -------
+
+ .. code:: python3
+
+ class NotModeratorError(commands.GuardFailure):
+ ...
+
+ class MyComponent(commands.Component):
+
+ # The guard below will be applied to every command contained in your component...
+ # This guard raises our custom exception for easily identifying the error in our handler...
+
+ @commands.Component.guard()
+ def is_moderator(self, ctx: commands.Context) -> bool:
+ if not ctx.chatter.moderator:
+ raise NotModeratorError
+
+ return True
+
+ @commands.command()
+ async def test(self, ctx: commands.Context) -> None:
+ await ctx.reply(f"You are a moderator of {ctx.channel}")
+
+ async def component_command_error(self, payload: commands.CommandErrorPayload) -> bool | None:
+ error = payload.exception
+ ctx = payload.context
+
+ if isinstance(error, NotModeratorError):
+ await ctx.reply("Only moderators can use this command!")
+
+ # This explicit False return stops the error from being dispatched anywhere else...
+ return False
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def wrapper(func: Callable[..., bool] | Callable[..., CoroC]) -> Callable[..., bool] | Callable[..., CoroC]:
+ setattr(func, "__component_guard__", True)
+ return func
+
+ return wrapper
diff --git a/twitchio/ext/commands/context.py b/twitchio/ext/commands/context.py
new file mode 100644
index 00000000..fa9ed317
--- /dev/null
+++ b/twitchio/ext/commands/context.py
@@ -0,0 +1,507 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import TYPE_CHECKING, Any, Literal, TypeAlias
+
+from .core import CommandErrorPayload
+from .exceptions import *
+from .view import StringView
+
+
+__all__ = ("Context",)
+
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Coroutine
+
+ from twitchio.models import SentMessage
+ from twitchio.models.eventsub_ import ChatMessage
+ from twitchio.user import Chatter, PartialUser
+
+ from .bot import Bot
+ from .components import Component
+ from .core import Command
+
+ PrefixT: TypeAlias = str | Iterable[str] | Callable[[Bot, ChatMessage], Coroutine[Any, Any, str | Iterable[str]]]
+
+
+class Context:
+ """The Context class constructed when a message is received and processed via the :func:`~twitchio.event_message`
+ event in a :class:`~.commands.Bot`.
+
+ This object is available in all :class:`~.commands.Command`'s, :class:`~.commands.Groups`'s and associated sub-commands
+ and all command related events. It is also included in various areas relating to command invocation, including,
+ Guards and Before and After Hooks.
+
+ The Context class is a useful tool which provides information surrounding the command invocation, the broadcaster
+ and chatter involved and provides many useful methods and properties for ease of us.
+
+ Usually you wouldn't construct this class this yourself, however it could be subclassed to implement custom functionality
+ or constructed from a message received via EventSub in :func:`~twitchio.event_message`.
+
+ Parameters
+ ----------
+ message: :class:`twitchio.ChatMessage`
+ The message object, usually received via :func:`~twitchio.event_message`.
+ bot: :class:`~.commands.Bot`
+ Your :class:`~.commands.Bot` class, this is required to perform multiple operations.
+ """
+
+ def __init__(self, message: ChatMessage, *, bot: Bot) -> None:
+ self._message: ChatMessage = message
+ self._bot: Bot = bot
+ self._component: Component | None = None
+ self._prefix: str | None = None
+
+ self._raw_content: str = self._message.text
+ self._command: Command[Any, ...] | None = None
+ self._invoked_subcommand: Command[Any, ...] | None = None
+ self._invoked_with: str | None = None
+ self._subcommand_trigger: str | None = None
+ self._command_failed: bool = False
+ self._error_dispatched: bool = False
+
+ self._failed: bool = False
+ self._passed_guards = False
+
+ self._view: StringView = StringView(self._raw_content)
+
+ self._args: list[Any] = []
+ self._kwargs: dict[str, Any] = {}
+
+ @property
+ def message(self) -> ChatMessage:
+ """Proptery of the message object that this context is built from. This is the :class:`~twitchio.ChatMessage`
+ received via EventSub from the chatter.
+ """
+ return self._message
+
+ @property
+ def component(self) -> Component | None:
+ """Property returning the :class:`~.commands.Component` that this context was used in, if the
+ :class:`~.commands.Command` belongs to it. This is only set once a :class:`~.commands.Command`
+ has been found and invoked.
+ """
+ return self._component
+
+ @property
+ def command(self) -> Command[Any, ...] | None:
+ """Property returning the :class:`~.commands.Command` associated with this context, if found.
+
+ This is only set when a command begins invocation. Could be ``None`` if the command has not started invocation,
+ or one was not found.
+ """
+ return self._command
+
+ @property
+ def invoked_subcommand(self) -> Command[Any, ...] | None:
+ """Property returning the subcommand associated with this context if their is one.
+
+ Returns ``None`` when a standard command without a parent :class:`~.commands.Group` is invoked.
+ """
+ return self._invoked_subcommand
+
+ @property
+ def subcommand_trigger(self) -> str | None:
+ return self._subcommand_trigger
+
+ @property
+ def invoked_with(self) -> str | None:
+ """Property returning the string the context used to attempt to find
+ a valid :class:`~.commands.Command`.
+
+ Could be ``None`` if a command has not been invoked from this context yet.
+ """
+ return self._invoked_with
+
+ @property
+ def chatter(self) -> Chatter:
+ """Property returning the :class:`twitchio.PartialUser` who sent the :class:`~twitchio.ChatMessage`
+ in the channel that this context is built from.
+ """
+ return self._message.chatter
+
+ @property
+ def author(self) -> Chatter:
+ """Alias to :attr:`.chatter`."""
+ return self._message.chatter
+
+ @property
+ def broadcaster(self) -> PartialUser:
+ """Property returning the :class:`twitchio.PartialUser` who is the broadcaster of the channel associated with this
+ context.
+ """
+ return self._message.broadcaster
+
+ @property
+ def source_broadcaster(self) -> PartialUser | None:
+ """Property returning the :class:`twitchio.PartialUser` who is the broadcaster of the channel associated with
+ the original :class:`~twitchio.ChatMessage`. This will usually always be ``None`` as the default behaviour is to
+ ignore shared messages when invoking commands.
+ """
+ return self._message.source_broadcaster
+
+ @property
+ def channel(self) -> PartialUser:
+ """An alias to :attr:`.broadcaster`."""
+ return self.broadcaster
+
+ @property
+ def bot(self) -> Bot:
+ """Property returning the :class:`~.commands.Bot` object."""
+ return self._bot
+
+ @property
+ def prefix(self) -> str | None:
+ """Property returning the prefix associated with this context or ``None``.
+
+ This will only return a prefix after the context has been prepared, which occurs during invocation of a command,
+ and after a valid prefix found.
+ """
+ return self._prefix
+
+ @property
+ def content(self) -> str:
+ """Property returning the raw content of the message associated with this context."""
+ return self._raw_content
+
+ @property
+ def error_dispatched(self) -> bool:
+ return self._error_dispatched
+
+ @error_dispatched.setter
+ def error_dispatched(self, value: bool, /) -> None:
+ self._error_dispatched = value
+
+ @property
+ def args(self) -> list[Any]:
+ """A list of arguments processed and passed to the :class:`~.commands.Command` callback.
+
+ This is only set after the command begins invocation.
+ """
+ return self._args
+
+ @property
+ def kwargs(self) -> dict[str, Any]:
+ """A dict of keyword-arguments processed and passed to the :class:`~.commands.Command` callback.
+
+ This is only set after the command begins invocation.
+ """
+ return self._kwargs
+
+ @property
+ def failed(self) -> bool:
+ """Property indicating whether the context failed to invoke the associated command."""
+ return self._failed
+
+ def is_owner(self) -> bool:
+ """Method which returns whether the chatter associated with this context is the owner of the bot.
+
+ .. warning::
+
+ You must have set the :attr:`~commands.Bot.owner_id` correctly first,
+ otherwise this method will return ``False``.
+
+ Returns
+ -------
+ bool
+ Whether the chatter that this context is associated with is the owner of this bot.
+ """
+ return self.chatter.id == self.bot.owner_id
+
+ def is_valid(self) -> bool:
+ """Method which indicates whether this context is valid. E.g. hasa valid command prefix."""
+ return self._prefix is not None
+
+ def _validate_prefix(self, potential: str | Iterable[str]) -> None:
+ text: str = self._message.text
+
+ if isinstance(potential, str):
+ if text.startswith(potential):
+ self._prefix = potential
+
+ return
+
+ for prefix in tuple(potential):
+ if not isinstance(prefix, str): # type: ignore
+ msg = f'Command prefix in iterable or iterable returned from coroutine must be "str", not: {type(prefix)}'
+ raise PrefixError(msg)
+
+ if text.startswith(prefix):
+ self._prefix = prefix
+ return
+
+ async def _get_prefix(self) -> None:
+ assigned: PrefixT = self._bot._get_prefix
+ potential: str | Iterable[str]
+
+ if callable(assigned):
+ potential = await assigned(self._bot, self._message)
+ else:
+ potential = assigned
+
+ if not isinstance(potential, Iterable): # type: ignore
+ msg = f'Command prefix must be a "str", "Iterable[str]" or a coroutine returning either. Not: {type(potential)}'
+ raise PrefixError(msg)
+
+ self._validate_prefix(potential)
+
+ def _get_command(self) -> None:
+ if not self.prefix:
+ return
+
+ commands = self._bot._commands
+ self._view.skip_string(self.prefix)
+
+ next_ = self._view.get_word()
+ self._invoked_with = next_
+ command = commands.get(next_)
+
+ if not command:
+ return
+
+ self._command = command
+ return
+
+ async def _prepare(self) -> None:
+ await self._get_prefix()
+ self._get_command()
+
+ async def prepare(self) -> None:
+ await self._prepare()
+
+ async def invoke(self) -> bool | None:
+ """|coro|
+
+ Invoke and process the command associated with this message context if it is valid.
+
+ This method first prepares the context for invocation, and checks whether the context has a
+ valid command with a valid prefix.
+
+ .. warning::
+
+ Usually you wouldn't use this method yourself, as it handled by TwitchIO interanally when
+ :meth:`~.commands.Bot.process_commands` is called in a :func:`twitchio.event_message` event.
+
+ .. important::
+
+ Due to the way this method works, the only error raised will be :exc:`~.commands.CommandNotFound`.
+ All other errors that occur will be sent to the :func:`twitchio.event_command_error` event.
+
+ Returns
+ -------
+ bool
+ If this method explicitly returns ``False`` the context is not valid. E.g. has no valid command prefix.
+ When ``True`` the command successfully completed invocation without error.
+ ``None``
+ Returned when the command is found and begins to process. This does not indicate the command was completed
+ successfully. See also :func:`twitchio.event_command_completed` for an event fired when a
+ command successfully completes the invocation process.
+
+ Raises
+ ------
+ CommandNotFound
+ The :class:`~.commands.Command` trying to be invoked could not be found.
+ """
+ await self.prepare()
+
+ if not self.is_valid():
+ return False
+
+ if not self._command:
+ raise CommandNotFound(f'The command "{self._invoked_with}" was not found.')
+
+ self.bot.dispatch("command_invoked", self)
+
+ try:
+ await self._command.invoke(self)
+ except CommandError as e:
+ self._failed = True
+ await self._command._dispatch_error(self, e)
+
+ if self._passed_guards:
+ try:
+ await self._bot.after_invoke(self)
+ if self._component:
+ await self._component.component_after_invoke(self)
+ except Exception as e:
+ payload = CommandErrorPayload(context=self, exception=CommandHookError(str(e), e))
+ self.bot.dispatch("command_error", payload=payload)
+ return
+
+ if not self._failed:
+ self.bot.dispatch("command_completed", self)
+
+ return True
+
+ async def send(self, content: str, *, me: bool = False) -> SentMessage:
+ """|coro|
+
+ Send a chat message to the channel associated with this context.
+
+ .. important::
+
+ You must have the ``user:write:chat`` scope. If an app access token is used,
+ then additionally requires the ``user:bot`` scope on the bot,
+ and either ``channel:bot`` scope from the broadcaster or moderator status.
+
+ See: ... for more information.
+
+ Parameters
+ ----------
+ content: str
+ The content of the message you would like to send. This cannot exceed ``500`` characters. Additionally the content
+ parameter will be stripped of all leading and trailing whitespace.
+ me: bool
+ An optional bool indicating whether you would like to send this message with the ``/me`` chat command.
+
+ Returns
+ -------
+ SentMessage
+ The payload received by Twitch after sending this message.
+
+ Raises
+ ------
+ HTTPException
+ Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code.
+ MessageRejectedError
+ Twitch rejected the message from various checks.
+ """
+ new = (f"/me {content}" if me else content).strip()
+ return await self.channel.send_message(sender=self.bot.bot_id, message=new)
+
+ async def reply(self, content: str, *, me: bool = False) -> SentMessage:
+ """|coro|
+
+ Send a chat message as a reply to the user who this message is associated with and to the channel associated with
+ this context.
+
+ .. important::
+
+ You must have the ``user:write:chat`` scope. If an app access token is used,
+ then additionally requires the ``user:bot`` scope on the bot,
+ and either ``channel:bot`` scope from the broadcaster or moderator status.
+
+ See: ... for more information.
+
+ Parameters
+ ----------
+ content: str
+ The content of the message you would like to send. This cannot exceed ``500`` characters. Additionally the content
+ parameter will be stripped of all leading and trailing whitespace.
+ me: bool
+ An optional bool indicating whether you would like to send this message with the ``/me`` chat command.
+
+ Returns
+ -------
+ SentMessage
+ The payload received by Twitch after sending this message.
+
+ Raises
+ ------
+ HTTPException
+ Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code.
+ MessageRejectedError
+ Twitch rejected the message from various checks.
+ """
+ new = (f"/me {content}" if me else content).strip()
+ return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self.message.id)
+
+ async def send_announcement(
+ self, content: str, *, color: Literal["blue", "green", "orange", "purple", "primary"] | None = None
+ ) -> None:
+ """|coro|
+
+ Send an announcement to the channel associated with this channel as the bot.
+
+ .. important::
+
+ The broadcaster of the associated channel must have granted your bot the ``moderator:manage:announcements`` scope.
+ See: ... for more information.
+
+ Parameters
+ ----------
+ content: str
+ The content of the announcement to send. This cannot exceed `500` characters. Announcements longer than ``500``
+ characters will be truncated instead by Twitch.
+ color: Literal["blue", "green", "orange", "purple", "primary"] | None
+ An optional colour to use for the announcement. If set to ``"primary``" or ``None``
+ the channels accent colour will be used instead. Defaults to ``None``.
+
+ Returns
+ -------
+ None
+
+ Raises
+ ------
+ HTTPException
+ Sending the announcement failed. Could be ``400``, ``401`` or any ``5xx`` status code.
+ """
+ await self.channel.send_announcement(
+ moderator=self.bot.bot_id, token_for=self.bot.bot_id, message=content, color=color
+ )
+
+ async def delete_message(self) -> None:
+ """|coro|
+
+ Delete the message associated with this context.
+
+ .. important::
+
+ The broadcaster of the associated channel must have granted your bot the ``moderator:manage:chat_messages`` scope.
+ See: ... for more information.
+
+ .. note::
+
+ You cannot delete messages from the broadcaster *or* any moderator, and the message must not be more than
+ ``6 hours`` old.
+
+ Raises
+ ------
+ HTTPException
+ Twitch failed to remove the message. Could be ``400``, ``401``, ``403``, ``404`` or any ``5xx`` status code.
+ """
+ await self.channel.delete_chat_messages(
+ moderator=self.bot.bot_id, token_for=self.bot.bot_id, message_id=self.message.id
+ )
+
+ async def clear_messages(self) -> None:
+ """|coro|
+
+ Clear all the chat messages from chat for the channel associated with this context.
+
+ .. important::
+
+ The broadcaster of the associated channel must have granted your bot the ``moderator:manage:chat_messages`` scope.
+ See: ... for more information.
+
+ Raises
+ ------
+ HTTPException
+ Twitch failed to remove the message. Could be ``400``, ``401``, ``403``, ``404`` or any ``5xx`` status code.
+ """
+ await self.channel.delete_chat_messages(moderator=self.bot.bot_id, token_for=self.bot.bot_id, message_id=None)
diff --git a/twitchio/ext/commands/converters.py b/twitchio/ext/commands/converters.py
new file mode 100644
index 00000000..9889ca6c
--- /dev/null
+++ b/twitchio/ext/commands/converters.py
@@ -0,0 +1,100 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from twitchio.user import User
+
+from .exceptions import *
+
+
+if TYPE_CHECKING:
+ from .bot import Bot
+ from .context import Context
+
+__all__ = ("_BaseConverter",)
+
+_BOOL_MAPPING: dict[str, bool] = {
+ "true": True,
+ "false": False,
+ "t": True,
+ "f": False,
+ "1": True,
+ "0": False,
+ "y": True,
+ "n": False,
+ "yes": True,
+ "no": False,
+}
+
+
+class _BaseConverter:
+ def __init__(self, client: Bot) -> None:
+ self.__client: Bot = client
+
+ self._MAPPING: dict[Any, Any] = {User: self._user}
+ self._DEFAULTS: dict[type, Any] = {str: str, int: int, float: float, bool: self._bool, type(None): type(None)}
+
+ def _bool(self, arg: str) -> bool:
+ try:
+ result = _BOOL_MAPPING[arg.lower()]
+ except KeyError:
+ pretty: str = " | ".join(f'"{k}"' for k in _BOOL_MAPPING)
+ raise BadArgument(f'Failed to convert "{arg}" to type bool. Expected any: [{pretty}]', value=arg)
+
+ return result
+
+ async def _user(self, context: Context, arg: str) -> User:
+ arg = arg.lower()
+ users: list[User]
+ msg: str = 'Failed to convert "{}" to User. A User with the ID or login could not be found.'
+
+ if arg.startswith("@"):
+ arg = arg.removeprefix("@")
+ users = await self.__client.fetch_users(logins=[arg])
+
+ if not users:
+ raise BadArgument(msg.format(arg), value=arg)
+
+ if arg.isdigit():
+ users = await self.__client.fetch_users(logins=[arg], ids=[arg])
+ else:
+ users = await self.__client.fetch_users(logins=[arg])
+
+ potential: list[User] = []
+
+ for user in users:
+ # ID's should be taken into consideration first...
+ if user.id == arg:
+ return user
+
+ elif user.name == arg:
+ potential.append(user)
+
+ if potential:
+ return potential[0]
+
+ raise BadArgument(msg.format(arg), value=arg)
diff --git a/twitchio/ext/commands/cooldowns.py b/twitchio/ext/commands/cooldowns.py
index ded01efc..58d36514 100644
--- a/twitchio/ext/commands/cooldowns.py
+++ b/twitchio/ext/commands/cooldowns.py
@@ -1,179 +1,344 @@
-# -*- coding: utf-8 -*-
-
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
from __future__ import annotations
+
+import abc
+import asyncio
+import datetime
import enum
-import time
+from collections.abc import Callable, Coroutine, Hashable
+from typing import TYPE_CHECKING, Any, Generic, Self, TypeAlias, TypeVar
-from .errors import *
+if TYPE_CHECKING:
+ import twitchio
-__all__ = (
- "Bucket",
- "Cooldown",
-)
+ from .context import Context
-class Bucket(enum.Enum):
- """
- Enum values for the different cooldown buckets.
-
- Parameters
- ------------
- default: :class:`enum.Enum`
- The default bucket.
- channel: :class:`enum.Enum`
- Cooldown is shared amongst all chatters per channel.
- member: :class:`enum.Enum`
- Cooldown operates on a per channel basis per user.
- user: :class:`enum.Enum`
- Cooldown operates on a user basis across all channels.
- subscriber: :class:`enum.Enum`
- Cooldown for subscribers.
- mod: :class:`enum.Enum`
- Cooldown for mods.
+__all__ = ("BaseCooldown", "Bucket", "BucketType", "Cooldown", "GCRACooldown")
+
+
+PT = TypeVar("PT")
+CT = TypeVar("CT")
+
+
+class BucketType(enum.Enum):
+ """Enum representing default implementations for the key argument in :func:`~.commands.cooldown`.
+
+ Attributes
+ ----------
+ default
+ The cooldown will be considered a global cooldown shared across every channel and user.
+ user
+ The cooldown will apply per user, accross all channels.
+ channel
+ The cooldown will apply to every user/chatter in the channel.
+ chatter
+ The cooldown will apply per user, per channel.
"""
default = 0
- channel = 1
- member = 2
- user = 3
- subscriber = 4
- mod = 5
+ user = 1
+ channel = 2
+ chatter = 3
+
+ def get_key(self, payload: twitchio.ChatMessage | Context) -> Any:
+ if self is BucketType.user:
+ return payload.chatter.id
+
+ elif self is BucketType.channel:
+ return ("channel", payload.broadcaster.id)
+
+ elif self is BucketType.chatter:
+ return (payload.broadcaster.id, payload.chatter.id)
+
+ def __call__(self, payload: twitchio.ChatMessage | Context) -> Any:
+ return self.get_key(payload)
+
+
+class BaseCooldown(abc.ABC):
+ """Base class used to implement your own cooldown algorithm for use with :func:`~.commands.cooldown`.
+
+ Some built-in cooldown algorithms already exist:
+
+ - :class:`~.commands.Cooldown` - (``Token Bucket Algorithm``)
+ - :class:`~.commands.GCRACooldown` - (``Generic Cell Rate Algorithm``)
-class Cooldown:
+
+ .. note::
+
+ Every base method must be implemented in this base class.
"""
- Cooldown decorator values.
-
- Parameters
- ------------
- rate: :class:`int`
- How many times the command can be invoked before triggering a cooldown inside a time frame.
- per: :class:`float`
- The amount of time in seconds to wait for a cooldown when triggered.
- bucket: :class:`Bucket`
- The bucket that the cooldown is in.
-
- Examples
- ----------
- .. code:: py
-
- # Restrict a command to once every 10 seconds on a per channel basis.
- @commands.cooldown(rate=1, per=10, bucket=commands.Bucket.channel)
- @commands.command()
- async def my_command(self, ctx: commands.Context):
- pass
-
- # Restrict a command to once every 30 seconds for each individual channel a user is in.
- @commands.cooldown(rate=1, per=30, bucket=commands.Bucket.member)
- @commands.command()
- async def my_command(self, ctx: commands.Context):
- pass
-
- # Restrict a command to 5 times every 60 seconds globally for a user.
- @commands.cooldown(rate=5, per=60, bucket=commands.Bucket.user)
- @commands.command()
- async def my_command(self, ctx: commands.Context):
- pass
+ @abc.abstractmethod
+ def reset(self) -> None:
+ """Base method which should be implemented to reset the cooldown."""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def update(self, *args: Any, **kwargs: Any) -> float | None:
+ """Base method which should be implemented to update the cooldown/ratelimit.
+
+ This is where your algorithm logic should be contained.
+
+ .. important::
+
+ This method should always return a :class:`float` or ``None``. If ``None`` is returned by this method,
+ the cooldown will be considered bypassed.
+
+ Returns
+ -------
+ :class:`float`
+ The time needed to wait before you are off cooldown.
+ ``None``
+ Bypasses the cooldown.
+ """
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def copy(self) -> Self:
+ """Base method which should be implemented to return a copy of this class in it's original state."""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def is_ratelimited(self, *args: Any, **kwargs: Any) -> bool:
+ """Base method which should be implemented which returns a bool indicating whether the cooldown is ratelimited.
+
+ Returns
+ -------
+ bool
+ A bool indicating whether this cooldown is currently ratelimited.
+ """
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def is_dead(self, *args: Any, **kwargs: Any) -> bool:
+ """Base method which should be implemented to indicate whether the cooldown should be considered stale and allowed
+ to be removed from the ``bucket: cooldown`` mapping.
+
+ Returns
+ -------
+ bool
+ A bool indicating whether this cooldown is stale/old.
+ """
+ raise NotImplementedError
+
+
+class Cooldown(BaseCooldown):
+ """Default cooldown algorithm for :func:`~.commands.cooldown`, which implements a ``Token Bucket Algorithm``.
+
+ See: :func:`~.commands.cooldown` for more documentation.
+ """
+
+ def __init__(self, *, rate: int, per: float | datetime.timedelta) -> None:
+ if rate <= 0:
+ raise ValueError(f'Cooldown rate must be equal to or greater than 1. Got "{rate}" expected >= 1.')
+
+ self._rate: int = rate
+ self._per: datetime.timedelta = datetime.timedelta(seconds=per) if not isinstance(per, datetime.timedelta) else per
+
+ now: datetime.datetime = datetime.datetime.now(tz=datetime.UTC)
+ self._window: datetime.datetime = now + self._per
+
+ if self._window <= now:
+ raise ValueError("The provided per value for Cooldowns can not go into the past.")
+
+ self._tokens: int = self._rate
+ self.last_updated: datetime.datetime | None = None
+
+ @property
+ def per(self) -> datetime.timedelta:
+ return self._per
+
+ def reset(self) -> None:
+ self._tokens = self._rate
+ self._window = datetime.datetime.now(tz=datetime.UTC) + self._per
+
+ def get_tokens(self, now: datetime.datetime | None = None) -> int:
+ if now is None:
+ now = datetime.datetime.now(tz=datetime.UTC)
+
+ tokens = max(self._tokens, 0)
+ if now > self._window:
+ tokens = self._rate
+
+ return tokens
+
+ def is_ratelimited(self) -> bool:
+ self._tokens = self.get_tokens()
+ return self._tokens == 0
+
+ def update(self, *, factor: int = 1) -> float | None:
+ now = datetime.datetime.now(tz=datetime.UTC)
+ self.last_updated = now
+
+ self._tokens = self.get_tokens(now)
+
+ if self._tokens == self._rate:
+ self._window = datetime.datetime.now(tz=datetime.UTC) + self._per
+
+ self._tokens -= factor
+
+ if self._tokens < 0:
+ remaining = (self._window - now).total_seconds()
+ return remaining
+
+ def copy(self) -> Self:
+ return self.__class__(rate=self._rate, per=self._per)
+
+ def is_dead(self) -> bool:
+ if self.last_updated is None:
+ return False
+
+ now = datetime.datetime.now(tz=datetime.UTC)
+ return now > (self.last_updated + self.per)
+
+
+class GCRACooldown(BaseCooldown):
+ """GCRA cooldown algorithm for :func:`~.commands.cooldown`, which implements the ``GCRA`` ratelimiting algorithm.
+
+ See: :func:`~.commands.cooldown` for more documentation.
"""
- __slots__ = ("_rate", "_per", "bucket", "_window", "_tokens", "_cache")
+ def __init__(self, *, rate: int, per: float | datetime.timedelta) -> None:
+ if rate <= 0:
+ raise ValueError(f'Cooldown rate must be equal to or greater than 1. Got "{rate}" expected >= 1.')
+
+ self._rate: int = rate
+ self._per: datetime.timedelta = datetime.timedelta(seconds=per) if not isinstance(per, datetime.timedelta) else per
+
+ now: datetime.datetime = datetime.datetime.now(tz=datetime.UTC)
+ self._tat: datetime.datetime | None = None
+
+ if (now + self._per) <= now:
+ raise ValueError("The provided per value for Cooldowns can not go into the past.")
+
+ self.last_updated: datetime.datetime | None = None
+
+ @property
+ def inverse(self) -> float:
+ return self._per.total_seconds() / self._rate
+
+ @property
+ def per(self) -> datetime.timedelta:
+ return self._per
+
+ def reset(self) -> None:
+ self.last_updated = None
+ self._tat = None
+
+ def is_ratelimited(self, *, now: datetime.datetime | None = None) -> bool:
+ now = now or datetime.datetime.now(tz=datetime.UTC)
+ tat: datetime.datetime = max(self._tat or now, now)
+
+ separation: float = (tat - now).total_seconds()
+ max_interval: float = self._per.total_seconds() - self.inverse
+
+ return separation > max_interval
+
+ def update(self) -> float | None:
+ now: datetime.datetime = datetime.datetime.now(tz=datetime.UTC)
+ tat: datetime.datetime = max(self._tat or now, now)
+
+ self.last_updated = now
- def __init__(self, rate: int, per: float, bucket: Bucket):
- self._rate = rate
- self._per = per
- self.bucket = bucket
+ separation: float = (tat - now).total_seconds()
+ max_interval: float = self._per.total_seconds() - self.inverse
- self._cache = {}
+ if separation > max_interval:
+ return separation - max_interval
- def update_bucket(self, ctx):
- now = time.time()
+ new = max(tat, now) + datetime.timedelta(seconds=self.inverse)
+ self._tat = new
- bucket_keys = self._bucket_keys(ctx)
- buckets = []
+ def copy(self) -> Self:
+ return self.__class__(rate=self._rate, per=self._per)
- for bucket in bucket_keys:
- (tokens, window) = self._cache[bucket]
+ def is_dead(self) -> bool:
+ if self.last_updated is None:
+ return False
- if tokens == self._rate:
- retry = self._per - (now - window)
- raise CommandOnCooldown(command=ctx.command, retry_after=retry)
+ now = datetime.datetime.now(tz=datetime.UTC)
+ return now > (self.last_updated + self.per)
- tokens += 1
- if tokens == self._rate:
- window = now
+KeyT: TypeAlias = Callable[[Any], Hashable] | Callable[[Any], Coroutine[Any, Any, Hashable]] | BucketType
- self._cache[bucket] = (tokens, window)
- def reset(self):
- self._cache = {}
+class Bucket(Generic[PT]):
+ def __init__(self, cooldown: BaseCooldown, *, key: KeyT) -> None:
+ self._cooldown: BaseCooldown = cooldown
+ self._cache: dict[Hashable, BaseCooldown] = {}
+ self._key: KeyT = key
- def _bucket_keys(self, ctx):
- buckets = []
+ @classmethod
+ def from_cooldown(cls, *, base: type[BaseCooldown], key: KeyT, **kwargs: Any) -> Self:
+ cd: BaseCooldown = base(**kwargs)
+ return cls(cd, key=key)
- for bucket in ctx.command._cooldowns:
- if bucket.bucket == Bucket.default:
- buckets.append("default")
+ def create_cooldown(self) -> BaseCooldown | None:
+ return self._cooldown.copy()
- if bucket.bucket == Bucket.channel:
- buckets.append(ctx.channel.name)
+ def verify_cache(self) -> None:
+ dead = [k for k, v in self._cache.items() if v.is_dead()]
+ for key in dead:
+ del self._cache[key]
- if bucket.bucket == Bucket.member:
- buckets.append((ctx.channel.name, ctx.author.id))
- if bucket.bucket == Bucket.user:
- buckets.append(ctx.author.id)
+ async def get_key(self, payload: PT) -> Hashable:
+ if asyncio.iscoroutinefunction(self._key):
+ key = await self._key(payload)
+ else:
+ key = self._key(payload) # type: ignore
- if bucket.bucket == Bucket.subscriber:
- buckets.append((ctx.channel.name, ctx.author.id, 0))
- if bucket.bucket == Bucket.mod:
- buckets.append((ctx.channel.name, ctx.author.id, 1))
+ return key
- return buckets
+ async def get_cooldown(self, payload: PT) -> BaseCooldown | None:
+ if self._key is BucketType.default:
+ return self._cooldown
- def _update_cache(self, now=None):
- now = now or time.time()
- dead = [key for key, cooldown in self._cache.items() if now > cooldown[1] + self._per]
+ self.verify_cache()
+ key = await self.get_key(payload)
+ if key is None:
+ return
- for bucket in dead:
- del self._cache[bucket]
+ if key not in self._cache:
+ cooldown = self.create_cooldown()
- def get_buckets(self, ctx):
- now = time.time()
+ if cooldown is not None:
+ self._cache[key] = cooldown
+ else:
+ cooldown = self._cache[key]
- self._update_cache(now)
+ return cooldown
- bucket_keys = self._bucket_keys(ctx)
- buckets = []
+ async def update(self, payload: PT, **kwargs: Any) -> float | None:
+ bucket = await self.get_cooldown(payload)
- for index, bucket in enumerate(bucket_keys):
- buckets.append(ctx.command._cooldowns[index])
- if bucket not in self._cache:
- self._cache[bucket] = (0, now)
+ if bucket is None:
+ return None
- return buckets
+ return bucket.update(**kwargs)
diff --git a/twitchio/ext/commands/core.py b/twitchio/ext/commands/core.py
index 2b40bdfb..e24b5fcd 100644
--- a/twitchio/ext/commands/core.py
+++ b/twitchio/ext/commands/core.py
@@ -1,695 +1,1318 @@
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
+Copyright (c) 2015 - present Rapptz
-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:
+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 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,
+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.
+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.
"""
from __future__ import annotations
-import inspect
-import itertools
+import asyncio
import copy
-import types
-from typing import Any, Union, Optional, Callable, Awaitable, Tuple, TYPE_CHECKING, List, Type, Set, TypeVar
-from typing_extensions import Literal
+import inspect
+from collections.abc import Callable, Coroutine, Generator
+from types import MappingProxyType, UnionType
+from typing import TYPE_CHECKING, Any, Concatenate, Generic, Literal, ParamSpec, TypeAlias, TypeVar, Union, Unpack, overload
+
+import twitchio
+from twitchio.utils import MISSING, unwrap_function
+
+from .cooldowns import BaseCooldown, Bucket, BucketType, Cooldown, KeyT
+from .exceptions import *
+from .types_ import CommandOptions, Component_T
+
+
+__all__ = (
+ "Command",
+ "CommandErrorPayload",
+ "Group",
+ "Mixin",
+ "command",
+ "cooldown",
+ "group",
+ "guard",
+ "is_broadcaster",
+ "is_elevated",
+ "is_moderator",
+ "is_owner",
+ "is_staff",
+ "is_vip",
+)
-from twitchio.abcs import Messageable
-from .cooldowns import *
-from .errors import *
-from . import builtin_converter
if TYPE_CHECKING:
- import sys
+ from twitchio.user import Chatter
- from twitchio import Message, Chatter, PartialChatter, Channel, User, PartialUser
- from . import Cog, Bot
- from .stringparser import StringParser
+ from .context import Context
- if sys.version_info >= (3, 10):
- UnionT = Union[types.UnionType, Union]
- else:
- UnionT = Union
+ P = ParamSpec("P")
+else:
+ P = TypeVar("P")
-__all__ = ("Command", "command", "Group", "Context", "cooldown")
+Coro: TypeAlias = Coroutine[Any, Any, None]
+CoroC: TypeAlias = Coroutine[Any, Any, bool]
+DT = TypeVar("DT")
+VT = TypeVar("VT")
-class EmptyArgumentSentinel:
- def __repr__(self) -> str:
- return ""
- def __eq__(self, __value: object) -> bool:
- return False
+def get_signature_parameters(
+ function: Callable[..., Any],
+ globalns: dict[str, Any],
+ /,
+ *,
+ skip_parameters: int | None = None,
+) -> dict[str, inspect.Parameter]:
+ signature = inspect.Signature.from_callable(function)
+ params: dict[str, inspect.Parameter] = {}
+ cache: dict[str, Any] = {}
+ eval_annotation = twitchio.utils.evaluate_annotation
+ required_params = twitchio.utils.is_inside_class(function) + 1 if skip_parameters is None else skip_parameters
-EMPTY = EmptyArgumentSentinel()
+ if len(signature.parameters) < required_params:
+ raise TypeError(f"Command signature requires at least {required_params - 1} parameter(s)")
+ iterator = iter(signature.parameters.items())
+ for _ in range(0, required_params):
+ next(iterator)
-def _boolconverter(_, param: str):
- param = param.lower()
- if param in {"yes", "y", "1", "true", "on"}:
- return True
- elif param in {"no", "n", "0", "false", "off"}:
- return False
- raise BadArgument(f"Expected a boolean value, got {param}")
+ for name, parameter in iterator:
+ annotation = parameter.annotation
+ if annotation is None:
+ params[name] = parameter.replace(annotation=type(None))
+ continue
-class Command:
- """A class for implementing bot commands.
+ annotation = eval_annotation(annotation, globalns, globalns, cache)
+ params[name] = parameter.replace(annotation=annotation)
+
+ return params
- Parameters
- ------------
- name: :class:`str`
- The name of the command.
- func: :class:`Callable`
- The coroutine that executes when the command is invoked.
+
+class CommandErrorPayload:
+ """Payload received in the :func:`~twitchio.event_command_error` event.
Attributes
- ------------
- name: :class:`str`
- The name of the command.
- cog: :class:`~twitchio.ext.commands.Cog`
- The cog this command belongs to.
- aliases: Optional[Union[:class:`list`, :class:`tuple`]]
- Aliases that can be used to also invoke the command.
+ ----------
+ context: :class:`~.commands.Context`
+ The context surrounding command invocation.
+ exception: :exc:`.commands.CommandError`
+ The exception raised during command invocation.
"""
- def __init__(self, name: str, func: Callable, **attrs) -> None:
- if not inspect.iscoroutinefunction(func):
- raise TypeError("Command callback must be a coroutine.")
- self._callback = func
- self._checks = []
- self._cooldowns = []
- self._name = name
+ __slots__ = ("context", "exception")
- self._instance = None
- self.cog = None
- self.parent: Optional[Group] = attrs.get("parent")
+ def __init__(self, *, context: Context, exception: CommandError) -> None:
+ self.context: Context = context
+ self.exception: CommandError = exception
- try:
- self._checks.extend(func.__checks__) # type: ignore
- except AttributeError:
- pass
- try:
- self._cooldowns.extend(func.__cooldowns__) # type: ignore
- except AttributeError:
- pass
- self.aliases = attrs.get("aliases", None)
- sig = inspect.signature(func)
- self.params = sig.parameters.copy() # type: ignore
- self.event_error = None
- self._before_invoke = None
- self._after_invoke = None
- self.no_global_checks = attrs.get("no_global_checks", False)
+class Command(Generic[Component_T, P]):
+ """The TwitchIO ``commands.Command`` class.
+
+ These are usually not created manually, instead see:
+
+ - :func:`.commands.command`
+
+ - :meth:`.commands.Bot.add_command`
+ """
+
+ def __init__(
+ self,
+ callback: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro],
+ *,
+ name: str,
+ **kwargs: Unpack[CommandOptions],
+ ) -> None:
+ self._name: str = name
+ self.callback = callback
+ self._aliases: list[str] = kwargs.get("aliases", [])
+ self._guards: list[Callable[..., bool] | Callable[..., CoroC]] = getattr(self._callback, "__command_guards__", [])
+ self._buckets: list[Bucket[Context]] = getattr(self._callback, "__command_cooldowns__", [])
+ self._guards_after_parsing = kwargs.get("guards_after_parsing", False)
+ self._cooldowns_first = kwargs.get("cooldowns_before_guards", False)
+
+ self._injected: Component_T | None = None
+ self._error: Callable[[Component_T, CommandErrorPayload], Coro] | Callable[[CommandErrorPayload], Coro] | None = None
+ self._extras: dict[Any, Any] = kwargs.get("extras", {})
+ self._parent: Group[Component_T, P] | None = kwargs.get("parent")
+ self._bypass_global_guards: bool = kwargs.get("bypass_global_guards", False)
- for key, value in self.params.items():
- if isinstance(value.annotation, str):
- self.params[key] = value.replace(annotation=eval(value.annotation, func.__globals__)) # type: ignore
+ def __repr__(self) -> str:
+ return f"Command(name={self._name}, parent={self.parent})"
+
+ def __str__(self) -> str:
+ return self._name
+
+ async def __call__(self, context: Context) -> None:
+ callback = self._callback(self._injected, context) if self._injected else self._callback(context) # type: ignore
+ await callback
+
+ @property
+ def component(self) -> Component_T | None:
+ """Property returning the :class:`~.commands.Component` associated with this command or
+ ``None`` if there is not one.
+ """
+ return self._injected
+
+ @property
+ def parent(self) -> Group[Component_T, P] | None:
+ """Property returning the :class:`~.commands.Group` this sub-command belongs to or ``None`` if it is not apart
+ of a group.
+ """
+ return self._parent
@property
def name(self) -> str:
+ """Property returning the name of this command."""
return self._name
@property
- def full_name(self) -> str:
- if not self.parent:
- return self._name
- return f"{self.parent.full_name} {self._name}"
+ def aliases(self) -> list[str]:
+ """Property returning a copy of the list of aliases associated with this command, if it has any set.
- def _is_optional_argument(self, converter: Any):
- return (getattr(converter, "__origin__", None) is Union or isinstance(converter, types.UnionType)) and type(
- None
- ) in converter.__args__
+ Could be an empty ``list`` if no aliases have been set.
+ """
+ return copy.copy(self._aliases)
- def resolve_union_callback(self, name: str, converter: UnionT) -> Callable[[Context, str], Any]:
- # print(type(converter), converter.__args__)
+ @property
+ def extras(self) -> MappingProxyType[Any, Any]:
+ """Property returning the extras stored on this command as :class:`MappingProxyType`.
- args = converter.__args__ # type: ignore # pyright doesnt like this
+ Extras is a dict that can contain any information, and is stored on the command object for future retrieval.
+ """
+ return MappingProxyType(self._extras)
- async def _resolve(context: Context, arg: str) -> Any:
- t = EMPTY
+ @property
+ def has_error(self) -> bool:
+ """Property returning a ``bool``, indicating whether this command has any local error handlers."""
+ return self._error is not None
- for original in args:
- underlying = self._resolve_converter(name, original, context)
+ @property
+ def guards(self) -> list[Callable[..., bool] | Callable[..., CoroC]]:
+ """Property returning a list of command specific :func:`.guard`'s added."""
+ return self._guards
- try:
- t: Any = underlying(context, arg)
- if inspect.iscoroutine(t):
- t = await t
+ @property
+ def callback(self) -> Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro]:
+ """Property returning the coroutine callback used in invocation.
+ E.g. the function you wrap with :func:`.command`.
+ """
+ return self._callback
- break
- except Exception as l:
- t = EMPTY # thisll get changed when t is a coroutine, but is still invalid, so roll it back
- continue
+ @callback.setter
+ def callback(
+ self, func: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro]
+ ) -> None:
+ self._callback = func
+ unwrap = unwrap_function(func)
+ self.module: str = unwrap.__module__
- if t is EMPTY:
- raise UnionArgumentParsingFailed(name, args)
+ try:
+ globalns = unwrap.__globals__
+ except AttributeError:
+ globalns = {}
- return t
+ self._params: dict[str, inspect.Parameter] = get_signature_parameters(func, globalns)
- return _resolve
+ def _convert_literal_type(
+ self, context: Context, param: inspect.Parameter, args: tuple[Any, ...], *, raw: str | None
+ ) -> Any:
+ name: str = param.name
+ result: Any = MISSING
- def resolve_optional_callback(self, name: str, converter: Any, context: Context) -> Callable[[Context, str], Any]:
- underlying = self._resolve_converter(name, converter.__args__[0], context)
+ for arg in reversed(args):
+ type_: type = type(arg)
+ base = context.bot._base_converter._DEFAULTS.get(type_)
- async def _resolve(context: Context, arg: str) -> Any:
- try:
- t: Any = underlying(context, arg)
- if inspect.iscoroutine(t):
- t = await t
+ if base:
+ try:
+ result = base(raw)
+ except Exception:
+ continue
- except Exception:
- return EMPTY # instruct the parser to roll back and ignore this argument
+ break
- return t
+ if result not in args:
+ pretty: str = " | ".join(str(a) for a in args)
+ raise BadArgument(f'Failed to convert Literal, expected any [{pretty}], got "{raw}".', name=name, value=raw)
- return _resolve
+ return result
- def _resolve_converter(
- self, name: str, converter: Union[Callable, Awaitable, type], ctx: Context
- ) -> Callable[..., Any]:
- if (
- isinstance(converter, type)
- and converter.__module__.startswith("twitchio")
- and converter in builtin_converter._mapping
- ):
- return self._convert_builtin_type(name, converter, builtin_converter._mapping[converter])
+ async def _do_conversion(self, context: Context, param: inspect.Parameter, *, annotation: Any, raw: str | None) -> Any:
+ name: str = param.name
- elif converter is bool:
- converter = self._convert_builtin_type(name, bool, _boolconverter)
+ if isinstance(annotation, UnionType) or getattr(annotation, "__origin__", None) is Union:
+ converters = list(annotation.__args__)
- elif converter in (str, int):
- original: type[str | int] = converter # type: ignore
- converter = self._convert_builtin_type(name, original, lambda _, arg: original(arg))
+ try:
+ converters.remove(type(None))
+ except ValueError:
+ pass
- elif self._is_optional_argument(converter):
- return self.resolve_optional_callback(name, converter, ctx)
+ result: Any = MISSING
- elif isinstance(converter, types.UnionType) or getattr(converter, "__origin__", None) is Union:
- return self.resolve_union_callback(name, converter) # type: ignore
+ for c in reversed(converters):
+ try:
+ result = await self._do_conversion(context, param=param, annotation=c, raw=raw)
+ except Exception:
+ continue
- elif hasattr(converter, "__metadata__"): # Annotated
- annotated = converter.__metadata__ # type: ignore
- return self._resolve_converter(name, annotated[0], ctx)
+ if result is MISSING:
+ raise BadArgument(
+ f'Failed to convert argument "{name}" with any converter from Union: {converters}.',
+ name=name,
+ value=raw,
+ )
- return converter # type: ignore
+ return result
- def _convert_builtin_type(
- self,
- arg_name: str,
- original: type,
- converter: Union[Callable[[Context, str], Any], Callable[[Context, str], Awaitable[Any]]],
- ) -> Callable[[Context, str], Awaitable[Any]]:
- async def resolve(ctx, arg: str) -> Any:
- try:
- t = converter(ctx, arg)
+ if getattr(annotation, "__origin__", None) is Literal:
+ result = self._convert_literal_type(context, param, annotation.__args__, raw=raw)
+ if result is MISSING:
+ raise BadArgument(
+ f"Failed to convert Literal, no converter found for types in {annotation.__args__}",
+ name=name,
+ value=raw,
+ )
- if inspect.iscoroutine(t):
- t = await t
+ return result
- return t
+ base = context.bot._base_converter._DEFAULTS.get(annotation, None if annotation != param.empty else str)
+ if base:
+ try:
+ result = base(raw)
except Exception as e:
- raise ArgumentParsingFailed(
- f"Failed to convert `{arg}` to expected type {original.__name__} for argument `{arg_name}`",
- original=e,
- argname=arg_name,
- expected=original,
- ) from e
+ raise BadArgument(f'Failed to convert "{name}" to {base}', name=name, value=raw) from e
- return resolve
+ return result
- async def _convert_types(self, context: Context, param: inspect.Parameter, parsed: str) -> Any:
- converter = param.annotation
+ converter = context.bot._base_converter._MAPPING.get(annotation, annotation)
- if converter is param.empty:
- if param.default in (param.empty, None):
- converter = str
- else:
- converter = type(param.default)
+ try:
+ result = converter(context, raw)
+ except Exception as e:
+ raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw) from e
- true_converter = self._resolve_converter(param.name, converter, context)
+ if not asyncio.iscoroutine(result):
+ return result
try:
- argument = true_converter(context, parsed)
- if inspect.iscoroutine(argument):
- argument = await argument
- except BadArgument as e:
- if e.name is None:
- e.name = param.name
-
- raise
+ result = await result
except Exception as e:
- raise ArgumentParsingFailed(
- f"Failed to parse `{parsed}` for argument {param.name}", original=e, argname=param.name, expected=None
- ) from e
- return argument
-
- async def parse_args(self, context: Context, instance: Optional[Cog], parsed: dict, index=0) -> Tuple[list, dict]:
- if isinstance(self, Group):
- parsed = parsed.copy()
- iterator = iter(self.params.items())
- args = []
+ raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw) from e
+
+ return result
+
+ async def _parse_arguments(self, context: Context) -> ...:
+ context._view.skip_ws()
+ params: list[inspect.Parameter] = list(self._params.values())
+
+ args: list[Any] = []
kwargs = {}
- try:
- next(iterator)
- if instance:
- next(iterator)
- except StopIteration:
- raise TwitchCommandError("self or ctx is a required argument which is missing.")
- for _, param in iterator:
- index += 1
- if param.kind == param.POSITIONAL_OR_KEYWORD:
- try:
- argument = parsed.pop(index)
- except (KeyError, IndexError):
- if self._is_optional_argument(param.annotation): # parameter is optional and at the end.
- args.append(param.default if param.default is not param.empty else None)
- continue
-
- if param.default is param.empty:
- raise MissingRequiredArgument(argname=param.name)
-
- args.append(param.default)
- else:
- _parsed_arg = await self._convert_types(context, param, argument)
-
- if _parsed_arg is EMPTY:
- parsed[index] = argument
- index -= 1
- args.append(param.default if param.default is not param.empty else None)
-
- continue
- else:
- args.append(_parsed_arg)
-
- elif param.kind == param.KEYWORD_ONLY:
- rest = " ".join(parsed.values())
- if rest.startswith(" "):
- rest = rest.lstrip(" ")
- if rest:
- rest = await self._convert_types(context, param, rest)
- elif param.default is param.empty:
- raise MissingRequiredArgument(argname=param.name)
- else:
- rest = param.default
- kwargs[param.name] = rest
- parsed.clear()
- break
+ for param in params:
+ if param.kind == param.KEYWORD_ONLY:
+ raw = context._view.read_rest()
+
+ if raw:
+ result = await self._do_conversion(context, param=param, raw=raw, annotation=param.annotation)
+ kwargs[param.name] = result
+ break
+
+ if param.default == param.empty:
+ raise MissingRequiredArgument(param=param)
+
+ kwargs[param.name] = param.default
+
elif param.kind == param.VAR_POSITIONAL:
- args.extend([await self._convert_types(context, param, argument) for argument in parsed.values()])
- parsed.clear()
+ packed: list[Any] = []
+
+ while True:
+ context._view.skip_ws()
+ raw = context._view.get_quoted_word()
+ if not raw:
+ break
+
+ result = await self._do_conversion(context, param=param, raw=raw, annotation=param.annotation)
+ packed.append(result)
+
+ args.extend(packed)
break
- if parsed:
- pass # TODO Raise Too Many Arguments.
+
+ elif param.kind == param.POSITIONAL_OR_KEYWORD:
+ raw = context._view.get_quoted_word()
+ context._view.skip_ws()
+
+ if raw:
+ result = await self._do_conversion(context, param=param, raw=raw, annotation=param.annotation)
+ args.append(result)
+ continue
+
+ if param.default == param.empty:
+ raise MissingRequiredArgument(param=param)
+
+ args.append(param.default)
+
return args, kwargs
- async def invoke(self, context: Context, *, index=0) -> None:
- # TODO Docs
- if not context.view:
- return
+ async def _guard_runner(self, guards: list[Callable[..., bool] | Callable[..., CoroC]], *args: Any) -> None:
+ exc_msg = f'The guard predicates for command "{self.name}" failed.'
- async def try_run(func, *, to_command=False):
+ for guard in guards:
try:
- await func
- except Exception as _e:
- if not to_command:
- context.bot.run_event("error", _e)
- else:
- context.bot.run_event("command_error", context, _e)
+ result = guard(*args)
+ if asyncio.iscoroutine(result):
+ result = await result
+ except GuardFailure:
+ raise
+ except Exception as e:
+ raise GuardFailure(exc_msg, guard=guard) from e
- try:
- args, kwargs = await self.parse_args(context, self._instance, context.view.words, index=index)
- except (MissingRequiredArgument, BadArgument) as e:
- if self.event_error:
- args_ = [self._instance, context] if self._instance else [context]
- await try_run(self.event_error(*args_, e))
+ if result is not True:
+ raise GuardFailure(exc_msg, guard=guard)
- context.bot.run_event("command_error", context, e)
- return
+ async def _run_guards(self, context: Context, *, with_cooldowns: bool = True) -> None:
+ if with_cooldowns and self._cooldowns_first:
+ await self._run_cooldowns(context)
- context.args, context.kwargs = args, kwargs
- check_result = await self.handle_checks(context)
+ # Run global guard first...
+ if not self._bypass_global_guards:
+ await self._guard_runner([context.bot.global_guard], context)
- if check_result is not True:
- context.bot.run_event("command_error", context, check_result)
- return
- limited = self._run_cooldowns(context)
+ # Run component guards next, if this command is in a component...
+ if self._injected is not None and self._injected.__all_guards__:
+ await self._guard_runner(self._injected.__all_guards__, self._injected, context)
- if limited:
- context.bot.run_event("command_error", context, limited[0])
- return
- instance = self._instance
- args = [instance, context] if instance else [context]
- await try_run(context.bot.global_before_invoke(context))
+ # Run command specific guards...
+ if self._guards:
+ await self._guard_runner(self._guards, context)
+
+ if with_cooldowns and not self._cooldowns_first:
+ await self._run_cooldowns(context)
+
+ async def _run_cooldowns(self, context: Context) -> None:
+ type_ = "group" if isinstance(self, Group) else "command"
+
+ for bucket in self._buckets:
+ cooldown = await bucket.get_cooldown(context)
+ if cooldown is None:
+ continue
+
+ retry = cooldown.update()
+ if retry is None:
+ continue
+
+ raise CommandOnCooldown(
+ f'The {type_} "{self}" is on cooldown. Try again in {retry} seconds.',
+ remaining=retry,
+ cooldown=cooldown,
+ )
+
+ async def _invoke(self, context: Context) -> None:
+ context._component = self._injected
+
+ if not self._guards_after_parsing:
+ await self._run_guards(context)
+ context._passed_guards = True
- if self._before_invoke:
- await try_run(self._before_invoke(*args), to_command=True)
try:
- await self._callback(*args, *context.args, **context.kwargs)
+ args, kwargs = await self._parse_arguments(context)
+ except (ConversionError, MissingRequiredArgument):
+ raise
except Exception as e:
- if self.event_error:
- await try_run(self.event_error(*args, e))
- context.bot.run_event("command_error", context, e)
- else:
- context.bot.run_event("command_complete", context)
- # Invoke our after command hooks
- if self._after_invoke:
- await try_run(self._after_invoke(*args), to_command=True)
- await try_run(context.bot.global_after_invoke(context))
+ raise ConversionError("An unknown error occurred converting arguments.") from e
- def _run_cooldowns(self, context: Context) -> Optional[List[CommandOnCooldown]]:
- try:
- buckets = self._cooldowns[0].get_buckets(context)
- except IndexError:
- return None
- expired = []
+ context._args = args
+ context._kwargs = kwargs
+
+ args: list[Any] = [context, *args]
+ args.insert(0, self._injected) if self._injected else None
+
+ if self._guards_after_parsing:
+ await self._run_guards(context)
+ context._passed_guards = True
+
+ if self._guards_after_parsing:
+ await self._run_cooldowns(context)
try:
- for bucket in buckets:
- bucket.update_bucket(context)
- except CommandOnCooldown as e:
- expired.append(e)
- return expired
+ await context.bot.before_invoke(context)
+ if self._injected is not None:
+ await self._injected.component_before_invoke(context)
+ except Exception as e:
+ raise CommandHookError(str(e), e) from e
- async def handle_checks(self, context: Context) -> Union[Literal[True], Exception]:
- # TODO Docs
+ callback = self._callback(*args, **kwargs) # type: ignore
- if not self.no_global_checks:
- checks = [predicate for predicate in itertools.chain(context.bot._checks, self._checks)]
- else:
- checks = self._checks
try:
- for predicate in checks:
- result = predicate(context)
-
- if inspect.isawaitable(result):
- result = await result # type: ignore
- if not result:
- raise CheckFailure(f"The check {predicate} for command {self.name} failed.")
- if self.cog and not await self.cog.cog_check(context):
- raise CheckFailure(f"The cog check for command <{self.name}> failed.")
- return True
+ await callback
except Exception as e:
- return e
+ raise CommandInvokeError(msg=str(e), original=e) from e
- async def __call__(self, context: Context, *, index=0) -> None:
- await self.invoke(context, index=index)
+ async def invoke(self, context: Context) -> None:
+ try:
+ await self._invoke(context)
+ except CommandError as e:
+ await self._dispatch_error(context, e)
+ except Exception as e:
+ error = CommandInvokeError(str(e), original=e)
+ await self._dispatch_error(context, error)
+ async def _dispatch_error(self, context: Context, exception: CommandError) -> None:
+ payload = CommandErrorPayload(context=context, exception=exception)
+
+ if self._error is not None:
+ if self._injected:
+ await self._error(self._injected, payload) # type: ignore
+ else:
+ await self._error(payload) # type: ignore
-class Group(Command):
- def __init__(self, *args, invoke_with_subcommand=False, **kwargs) -> None:
- super(Group, self).__init__(*args, **kwargs)
- self._sub_commands = {}
- self._invoke_with_subcommand = invoke_with_subcommand
+ result = True
+ if self._injected is not None:
+ result = await self._injected.component_command_error(payload=payload)
- async def __call__(self, context: Context, *, index=0) -> None:
- if not context.view:
+ # If the component error handler returns explicit False, we won't further dispatch the error...
+ if result is False:
return
- if not context.view.words:
- return await self.invoke(context, index=index)
- arg: Tuple[int, str] = list(context.view.words.items())[0] # type: ignore
- if arg[1] in self._sub_commands:
- _ctx = copy.copy(context)
- _ctx.view = _ctx.view.copy()
- _ctx.view.words.pop(arg[0])
- await self._sub_commands[arg[1]](_ctx, index=arg[0])
-
- if self._invoke_with_subcommand:
- await self.invoke(context, index=index)
- else:
- await self.invoke(context, index=index)
- def command(
- self, *, name: str = None, aliases: Union[list, tuple] = None, cls=Command, no_global_checks=False
- ) -> Callable[[Callable], Command]:
- if cls and not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
-
- def decorator(func: Callable):
- fname = name or func.__name__
- cmd = cls(name=fname, func=func, aliases=aliases, no_global_checks=no_global_checks, parent=self)
- self._sub_commands[cmd.name] = cmd
- if cmd.aliases:
- for a in cmd.aliases:
- self._sub_commands[a] = cmd
- return cmd
-
- return decorator
+ context.error_dispatched = True
+ context.bot.dispatch("command_error", payload=payload)
- def group(
+ def error(
self,
- *,
- name: str = None,
- aliases: Union[list, tuple] = None,
- cls: Type[Group] = None,
- no_global_checks=False,
- invoke_with_subcommand=False,
- ) -> Callable[[Callable], Group]:
- cls = cls or Group
- if cls and not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
-
- def decorator(func: Callable):
- fname = name or func.__name__
- cmd = cls(
- name=fname,
- func=func,
- aliases=aliases,
- no_global_checks=no_global_checks,
- parent=self,
- invoke_with_subcommand=invoke_with_subcommand,
- )
- self._sub_commands[cmd.name] = cmd
- if cmd.aliases:
- for a in cmd.aliases:
- self._sub_commands[a] = cmd
- return cmd
+ func: Callable[[Component_T, CommandErrorPayload], Coro] | Callable[[CommandErrorPayload], Coro],
+ ) -> Callable[[Component_T, CommandErrorPayload], Coro] | Callable[[CommandErrorPayload], Coro]:
+ """|deco|
+
+ A decorator which adds a local error handler to this command.
+
+ Similar to :meth:`~commands.Bot.event_command_error` except local to this command.
+ """
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError(f'Command specific "error" callback for "{self._name}" must be a coroutine function.')
+
+ self._error = func
+ return func
+
+ def before_invoke(self) -> None: ...
+
+ def after_invoke(self) -> None: ...
+
+
+class Mixin(Generic[Component_T]):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ case_: bool = kwargs.pop("case_insensitive", False)
+ self._case_insensitive: bool = case_
+ self._commands: dict[str, Command[Component_T, ...]] = {} if not case_ else _CaseInsensitiveDict()
+
+ super().__init__(*args, **kwargs)
+
+ @property
+ def case_insensitive(self) -> bool:
+ """Property returning a bool indicating whether this Mixin is using case insensitive commands."""
+ return self._case_insensitive
+
+ def add_command(self, command: Command[Component_T, ...], /) -> None:
+ """Add a :class:`~.commands.Command` object to the mixin.
+
+ For group commands you would usually use the :meth:`~.Group.command` decorator instead.
+
+ See: :func:`~.commands.command`.
+ """
+ if not isinstance(command, Command): # type: ignore
+ raise TypeError(f'Expected "{Command}" got "{type(command)}".')
+
+ if command.name in self._commands:
+ raise CommandExistsError(f'A command with the name "{command.name}" is already registered.')
+
+ name: str = command.name
+ self._commands[name] = command
+
+ for alias in command.aliases:
+ if alias in self._commands:
+ self.remove_command(name)
+ raise CommandExistsError(f'A command with the alias "{alias}" already exists.')
+
+ self._commands[alias] = command
+
+ def remove_command(self, name: str, /) -> Command[Any, ...] | None:
+ """Remove a :class:`~.commands.Command` object from the mixin by it's name.
+
+ Parameters
+ ----------
+ name: str
+ The name of the :class:`~.commands.Command` to remove that was previously added.
+
+ Returns
+ -------
+ None
+ No commands with provided name were found.
+ Command
+ The :class:`~.commands.Command` which was removed.
+ """
+ command = self._commands.pop(name, None)
+ if not command:
+ return
+
+ if name in command.aliases:
+ return command
+
+ for alias in command.aliases:
+ cmd = self._commands.pop(alias, None)
+
+ if cmd is not None and cmd != command:
+ self._commands[alias] = cmd
+
+ return command
+
+
+def command(
+ name: str | None = None, aliases: list[str] | None = None, extras: dict[Any, Any] | None = None, **kwargs: Any
+) -> Any:
+ """|deco|
+
+ A decorator which turns a coroutine into a :class:`~.commands.Command` which can be used in
+ :class:`~.commands.Component`'s or added to a :class:`~.commands.Bot`.
- return decorator
+ Commands are powerful tools which enable bots to process messages and convert the content into mangeable arguments and
+ :class:`~.commands.Context` which is parsed to the wrapped callback coroutine.
+ Commands also benefit to such things as :func:`~.guard`'s and the ``before`` and ``after`` hooks on both,
+ :class:`~.commands.Component` and :class:`~.commands.Bot`.
-class Context(Messageable):
+ Command callbacks should take in at minimum one parameter, which is :class:`~.commands.Context` and is always
+ passed.
+
+ Parameters
+ ----------
+ name: str | None
+ An optional custom name to use for this command. If this is ``None`` or not passed, the coroutine function name
+ will be used instead.
+ aliases: list[str] | None
+ An optional list of aliases to use for this command.
+ extras: dict
+ A dict of any data which is stored on this command object. Can be used anywhere you have access to the command object,
+ E.g. in a ``before`` or ``after`` hook.
+ guards_after_parsing: bool
+ An optional bool, indicating whether to run guards after argument parsing has completed.
+ Defaults to ``False``, which means guards will be checked **before** command arguments are parsed and available.
+ cooldowns_before_guards: bool
+ An optional bool, indicating whether to run cooldown guards after all other guards succeed.
+ Defaults to ``False``, which means cooldowns will be checked **after** all guards have successfully completed.
+ bypass_global_guards: bool
+ An optional bool, indicating whether the command should bypass the :meth:`.Bot.global_guard`.
+ Defaults to ``False``.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ # When added to a Bot or used in a component you can invoke this command with your prefix, E.g:
+ # !hi or !howdy
+
+ @commands.command(name="hi", aliases=["hello", "howdy"])
+ async def hi_command(ctx: commands.Context) -> None:
+ ...
+
+ Raises
+ ------
+ ValueError
+ The callback being wrapped is already a command.
+ TypeError
+ The callback must be a coroutine function.
"""
- A class that represents the context in which a command is being invoked under.
- This class contains the meta data to help you understand more about the invocation context.
- This class is not created manually and is instead passed around to commands as the first parameter.
+ def wrapper(
+ func: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro],
+ ) -> Command[Any, ...]:
+ if isinstance(func, Command):
+ raise ValueError(f'Callback "{func._callback}" is already a Command.') # type: ignore
- Attributes
- -----------
- message: :class:`~twitchio.Message`
- The message that triggered the command being executed.
- channel: :class:`~twitchio.Channel`
- The channel the command was invoked in.
- author: Union[:class:`~twitchio.PartialChatter`, :class:`~twitchio.Chatter`]
- The Chatter object of the user in chat that invoked the command.
- prefix: Optional[:class:`str`]
- The prefix that was used to invoke the command.
- command: Optional[:class:`~twitchio.ext.commands.Command`]
- The command that was invoked
- cog: Optional[:class:`~twitchio.ext.commands.Cog`]
- The cog that contains the command that was invoked.
- args: Optional[List[:class:`Any`]]
- List of arguments that were passed to the command.
- kwargs: Optional[Dict[:class:`str`, :class:`Any`]]
- List of kwargs that were passed to the command.
- view: Optional[:class:`~twitchio.ext.commmands.StringParser`]
- StringParser object that breaks down the command string received.
- bot: :class:`~twitchio.ext.commands.Bot`
- The bot that contains the command that was invoked.
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError(f'Command callback for "{func.__qualname__}" must be a coroutine function.')
+
+ func_name = func.__name__
+ name_ = name.strip().replace(" ", "") or func_name if name else func_name
+
+ return Command(name=name_, callback=func, aliases=aliases or [], extras=extras or {}, **kwargs)
+
+ return wrapper
+
+
+def group(
+ name: str | None = None, aliases: list[str] | None = None, extras: dict[Any, Any] | None = None, **kwargs: Any
+) -> Any:
+ """|deco|
+
+ A decorator which turns a coroutine into a :class:`~.commands.Group` which can be used in
+ :class:`~.commands.Component`'s or added to a :class:`~.commands.Bot`.
+
+ Group commands act as parents to other commands (sub-commands).
+
+ See: :func:`.~commands.command` for more information on commands.
+
+ Group commands are a powerful way of grouping similar sub-commands into a more user friendly interface.
+
+ Group callbacks should take in at minimum one parameter, which is :class:`~.commands.Context` and is always
+ passed.
+
+ Parameters
+ ----------
+ name: str | None
+ An optional custom name to use for this group. If this is ``None`` or not passed, the coroutine function name
+ will be used instead.
+ aliases: list[str] | None
+ An optional list of aliases to use for this group.
+ extras: dict
+ A dict of any data which is stored on this command object. Can be used anywhere you have access to the command object,
+ E.g. in a ``before`` or ``after`` hook.
+ invoke_fallback: bool
+ An optional bool which tells the parent to be invoked as a fallback when no sub-command can be found.
+ Defaults to ``False``.
+ apply_cooldowns: bool
+ An optional bool indicating whether the cooldowns on this group are checked before invoking any sub commands.
+ Defaults to ``True``.
+ apply_guards: bool
+ An optional bool indicating whether the guards on this group should be ran before invoking any sub commands.
+ Defaults to ``True``.
+
+ Examples
+ --------
+
+ .. code:: python3
+
+ # When added to a Bot or used in a component you can invoke this group and sub-commands with your prefix, E.g:
+ # !socials
+ # !socials discord OR !socials twitch
+ # When invoke_fallback is True, the parent command will be invoked if a sub-command cannot be found...
+
+ @commands.group(name="socials", invoke_fallback=True)
+ async def socials_group(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF, https://twitch.tv/chillymosh, ...")
+
+ @socials_group.command(name="discord", aliases=["disco"])
+ async def socials_discord(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF")
+
+ @socials_group.command(name="twitch")
+ async def socials_twitch(ctx: commands.Context) -> None:
+ await ctx.send("https://twitch.tv/chillymosh")
+
+ Raises
+ ------
+ ValueError
+ The callback being wrapped is already a command or group.
+ TypeError
+ The callback must be a coroutine function.
"""
- __messageable_channel__ = True
+ def wrapper(
+ func: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro],
+ ) -> Group[Any, ...]:
+ if isinstance(func, Command):
+ raise ValueError(f'Callback "{func._callback.__name__}" is already a Command.') # type: ignore
- def __init__(self, message: Message, bot: Bot, **attrs) -> None:
- self.message: Message = message
- self.channel: Channel = message.channel
- self.author: Union[Chatter, PartialChatter] = message.author
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError(f'Group callback for "{func.__qualname__}" must be a coroutine function.')
- self.prefix: Optional[str] = attrs.get("prefix")
+ func_name = func.__name__
+ name_ = name.strip().replace(" ", "") or func_name if name else func_name
- self.command: Optional[Command] = attrs.get("command")
- if self.command:
- self.cog: Optional[Cog] = self.command.cog
- self.args: Optional[list] = attrs.get("args")
- self.kwargs: Optional[dict] = attrs.get("kwargs")
+ return Group(name=name_, callback=func, aliases=aliases or [], extras=extras or {}, **kwargs)
- self.view: Optional[StringParser] = attrs.get("view")
- self.is_valid: bool = attrs.get("valid")
+ return wrapper
- self.bot: Bot = bot
- self._ws = self.author._ws
- def _fetch_channel(self) -> Messageable:
- return self.channel or self.author # Abstract method
+class Group(Mixin[Component_T], Command[Component_T, P]):
+ """The TwitchIO ``commands.Command`` class.
- def _fetch_websocket(self):
- return self._ws # Abstract method
+ These are usually not created manually, instead see:
- def _fetch_message(self):
- return self.message # Abstract method
+ - :func:`.commands.group`
- def _bot_is_mod(self) -> bool:
- if not self.channel:
- return False
- cache = self._ws._cache[self.channel._name]
- for user in cache:
- if user.name == self._ws.nick:
- try:
- mod = user.is_mod
- except AttributeError:
- return False
- return mod
+ - :meth:`.commands.Bot.add_command`
+ """
- @property
- def chatters(self) -> Optional[Set[Chatter]]:
- """The channels current chatters."""
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+ self._invoke_fallback: bool = kwargs.get("invoke_fallback", False)
+ self._apply_cooldowns: bool = kwargs.get("apply_cooldowns", True)
+ self._apply_guards: bool = kwargs.get("apply_guards", True)
+
+ def walk_commands(self) -> Generator[Command[Component_T, P] | Group[Component_T, P]]:
+ """A generator which recursively walks through the sub-commands and sub-groups of this group."""
+ for command in self._commands.values():
+ yield command
+
+ if isinstance(command, Group):
+ yield from command.walk_commands()
+
+ async def _invoke(self, context: Context) -> None:
+ view = context._view
+ view.skip_ws()
+ trigger = view.get_word()
+
+ next_ = self._commands.get(trigger, None)
+ context._command = next_ or self
+ context._invoked_subcommand = next_
+ context._invoked_with = f"{context._invoked_with} {trigger}"
+ context._subcommand_trigger = trigger or None
+
+ if not trigger or (not next_ and self._invoke_fallback):
+ view.undo()
+ await super()._invoke(context=context)
+
+ elif next_:
+ if self._apply_cooldowns:
+ await super()._run_cooldowns(context)
+
+ if self._apply_guards:
+ await super()._run_guards(context, with_cooldowns=False)
+
+ await next_.invoke(context=context)
+
+ else:
+ raise CommandNotFound(f'The sub-command "{trigger}" for group "{self._name}" was not found.')
+
+ async def invoke(self, context: Context) -> None:
try:
- users = self._ws._cache[self.channel._name]
- except (KeyError, AttributeError):
- return None
- return users
+ await self._invoke(context)
+ except CommandError as e:
+ await self._dispatch_error(context, e)
- @property
- def users(self) -> Optional[Set[Chatter]]: # Alias to chatters
- """Alias to chatters."""
- return self.chatters
+ def command(
+ self,
+ name: str | None = None,
+ aliases: list[str] | None = None,
+ extras: dict[Any, Any] | None = None,
+ **kwargs: Any,
+ ) -> Any:
+ """|deco|
- def get_user(self, name: str) -> Optional[Union[PartialChatter, Chatter]]:
- """Retrieve a user from the channels user cache.
+ A decorator which adds a :class:`~.commands.Command` as a sub-command to this group.
- Parameters
- -----------
- name: str
- The user's name to try and retrieve.
+ See: :class:`~.commands.command` for more information on commands
- Returns
+ Examples
--------
- Union[:class:`twitchio.Chatter`, :class:`twitchio.PartialChatter`]
- Could be a :class:`twitchio.PartialChatter` depending on how the user joined the channel.
- Returns None if no user was found.
- """
- name = name.lower()
- if not self.channel:
- return None
- cache = self._ws._cache[self.channel._name]
- for user in cache:
- if user.name == name:
- return user
- return None
+ .. code:: python3
- async def reply(self, content: str):
- """|coro|
+ # When added to a Bot or used in a component you can invoke this group and sub-commands with your prefix, E.g:
+ # !socials
+ # !socials discord OR !socials twitch
+ # When invoke_fallback is True, the parent command will be invoked if a sub-command cannot be found...
+ @commands.group(name="socials", invoke_fallback=True)
+ async def socials_group(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF, https://twitch.tv/chillymosh, ...")
- Send a message in reply to the user who sent a message in the destination
- associated with the dataclass.
+ @socials_group.command(name="discord", aliases=["disco"])
+ async def socials_discord(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF")
- Destination will be the context of which the message/command was sent.
+ @socials_group.command(name="twitch")
+ async def socials_twitch(ctx: commands.Context) -> None:
+ await ctx.send("https://twitch.tv/chillymosh")
Parameters
- ------------
- content: str
- The content you wish to send as a message. The content must be a string.
+ ----------
+ name: str | None
+ An optional custom name to use for this sub-command. If this is ``None`` or not passed, the coroutine function name
+ will be used instead.
+ aliases: list[str] | None
+ An optional list of aliases to use for this command.
+ extras: dict
+ A dict of any data which is stored on this command object. Can be used anywhere you have access to the command object,
+ E.g. in a ``before`` or ``after`` hook.
+ guards_after_parsing: bool
+ An optional bool, indicating whether to run guards after argument parsing has completed.
+ Defaults to ``False``, which means guards will be checked **before** command arguments are parsed and available.
+ cooldowns_before_guards: bool
+ An optional bool, indicating whether to run cooldown guards after all other guards succeed.
+ Defaults to ``False``, which means cooldowns will be checked **after** all guards have successfully completed.
+ bypass_global_guards: bool
+ An optional bool, indicating whether the command should bypass the :func:`~.commands.Bot.global_guard`.
+ Defaults to ``False``.
+ """
+
+ def wrapper(
+ func: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro],
+ ) -> Command[Any, ...]:
+ new = command(name=name, aliases=aliases, extras=extras, parent=self, **kwargs)(func)
- Raises
+ self.add_command(new)
+ return new
+
+ return wrapper
+
+ def group(
+ self, name: str | None = None, aliases: list[str] | None = None, extras: dict[Any, Any] | None = None, **kwargs: Any
+ ) -> Any:
+ """|deco|
+
+ A decorator which adds a :class:`~.commands.Group` as a sub-group to this group.
+
+ Examples
--------
- InvalidContent
- Invalid content.
+
+ .. code:: python3
+
+ # When added to a Bot or used in a component you can invoke this group and sub-commands with your prefix, E.g:
+ # !socials
+ # !socials discord OR !socials twitch
+ # !socials discord one OR !socials discord two
+ # When invoke_fallback is True, the parent command will be invoked if a sub-command cannot be found...
+
+ @commands.group(name="socials", invoke_fallback=True)
+ async def socials_group(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF, https://twitch.tv/chillymosh, ...")
+
+ @socials_group.command(name="twitch")
+ async def socials_twitch(ctx: commands.Context) -> None:
+ await ctx.send("https://twitch.tv/chillymosh")
+
+ # Add a group to our parent group which further separates the commands...
+ @socials_group.group(name="discord", aliases=["disco"], invoke_fallback=True)
+ async def socials_discord(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF, https://discord.gg/...")
+
+ @socials_discord.command(name="one", aliases=["1"])
+ async def socials_discord_one(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/RAKc3HF")
+
+ @socials_discord.command(name="two", aliases=["2"])
+ async def socials_discord_two(ctx: commands.Context) -> None:
+ await ctx.send("https://discord.gg/...")
+
+ Parameters
+ ----------
+ name: str | None
+ An optional custom name to use for this group. If this is ``None`` or not passed, the coroutine function name
+ will be used instead.
+ aliases: list[str] | None
+ An optional list of aliases to use for this group.
+ extras: dict
+ A dict of any data which is stored on this command object. Can be used anywhere you have access to the command object,
+ E.g. in a ``before`` or ``after`` hook.
+ invoke_fallback: bool
+ An optional bool which tells the parent to be invoked as a fallback when no sub-command can be found.
+ Defaults to ``False``.
+ apply_cooldowns: bool
+ An optional bool indicating whether the cooldowns on this group are checked before invoking any sub commands.
+ Defaults to ``True``.
+ apply_guards: bool
+ An optional bool indicating whether the guards on this group should be ran before invoking any sub commands.
+ Defaults to ``True``.
"""
- entity = self._fetch_channel()
- ws = self._fetch_websocket()
- message = self._fetch_message()
- self.check_content(content)
- self.check_bucket(channel=entity.name)
+ def wrapper(
+ func: Callable[Concatenate[Component_T, Context, P], Coro] | Callable[Concatenate[Context, P], Coro],
+ ) -> Command[Any, ...]:
+ new = group(name=name, aliases=aliases, extras=extras, parent=self, **kwargs)(func)
+
+ self.add_command(new)
+ return new
+
+ return wrapper
+
+
+def guard(predicate: Callable[..., bool] | Callable[..., CoroC]) -> Any:
+ """A function which takes in a predicate as a either a standard function *or* coroutine function which should
+ return either ``True`` or ``False``, and adds it to your :class:`~.commands.Command` as a guard.
+
+ The predicate function should take in one parameter, :class:`.commands.Context`, the context used in command invocation.
+
+ If the predicate function returns ``False``, the chatter will not be able to invoke the command and an error will be
+ raised. If the predicate function returns ``True`` the chatter will be able to invoke the command,
+ assuming all the other guards also pass their predicate checks.
+
+ Guards can also raise custom exceptions, however your exception should inherit from :exc:`~.commands.GuardFailure` which
+ will allow your exception to propagate successfully to error handlers.
+
+ Any number of guards can be used on a :class:`~.commands.Command` and all must pass for the command to be successfully
+ invoked.
+
+ All guards are executed in the specific order displayed below:
+
+ - **Global Guard:** :meth:`.commands.Bot.global_guard`
+
+ - **Component Guards:** :meth:`.commands.Component.guard`
+
+ - **Command Specific Guards:** The command specific guards, E.g. by using this or other guard decorators on a command.
+
+ .. note::
+
+ Guards are checked and ran **after** all command arguments have been parsed and converted, but **before** any
+ ``before_invoke`` hooks are ran.
+
+ It is easy to create simple decorator guards for your commands, see the examples below.
+
+ Some built-in helper guards have been premade, and are listed below:
+
+ - :func:`~.commands.is_staff`
+
+ - :func:`~.commands.is_broadcaster`
+
+ - :func:`~.commands.is_moderator`
+
+ - :func:`~.commands.is_vip`
+
+ - :func:`~.commands.is_elevated`
+
+ Example
+ -------
+
+ .. code:: python3
+
+ def is_cool():
+ def predicate(ctx: commands.Context) -> bool:
+ return ctx.chatter.name.startswith("cool")
+
+ return commands.guard(predicate)
+
+ @is_cool()
+ @commands.command()
+ async def cool(self, ctx: commands.Context) -> None:
+ await ctx.reply("You are cool...!")
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def wrapper(func: Any) -> Any:
+ if isinstance(func, Command):
+ func._guards.append(predicate)
- try:
- name = entity.channel.name
- except AttributeError:
- name = entity.name
- if entity.__messageable_channel__:
- await ws.reply(message.id, f"PRIVMSG #{name} :{content}\r\n")
else:
- await ws.send(f"PRIVMSG #jtv :/w {name} {content}\r\n")
+ try:
+ func.__command_guards__.append(predicate)
+ except AttributeError:
+ func.__command_guards__ = [predicate]
+ return func # type: ignore
-C = TypeVar("C", bound="Command")
-G = TypeVar("G", bound="Group")
+ return wrapper
-def command(
- *, name: str = None, aliases: Union[list, tuple] = None, cls: type[C] = Command, no_global_checks=False
-) -> Callable[[Callable], C]:
- if cls and not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
+def is_owner() -> Any:
+ """|deco|
- def decorator(func: Callable) -> C:
- fname = name or func.__name__
- return cls(
- name=fname,
- func=func,
- aliases=aliases,
- no_global_checks=no_global_checks,
- )
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
- return decorator
+ This guards adds a predicate which prevents any chatter from using a command
+ who does is not the owner of this bot. You can set the owner of the bot via :attr:`~.commands.Bot.owner_id`.
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ return context.chatter.id == context.bot.owner_id
+
+ return guard(predicate)
+
+
+def is_staff() -> Any:
+ """|deco|
+
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
+
+ This guards adds a predicate which prevents any chatter from using a command
+ who does not possess the ``Twitch Staff`` badge.
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ return context.chatter.staff
+
+ return guard(predicate)
-def group(
- *,
- name: str = None,
- aliases: Union[list, tuple] = None,
- cls: G = Group,
- no_global_checks=False,
- invoke_with_subcommand=False,
-) -> Callable[[Callable], G]:
- if cls and not inspect.isclass(cls):
- raise TypeError(f"cls must be of type not <{type(cls)}>")
- def decorator(func: Callable) -> G:
- fname = name or func.__name__
- return cls(
- name=fname,
- func=func,
- aliases=aliases,
- no_global_checks=no_global_checks,
- invoke_with_subcommand=invoke_with_subcommand,
- )
+def is_broadcaster() -> Any:
+ """|deco|
- return decorator
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
+ This guards adds a predicate which prevents any chatter from using a command
+ who does not possess the ``Broadcaster`` badge.
-FN = TypeVar("FN")
+ See also, :func:`~.commands.is_elevated` for a guard to allow the ``broadcaster``, any ``moderator`` or ``VIP`` chatter
+ to use the command.
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ return context.chatter.id == context.broadcaster.id
+
+ return guard(predicate)
+
+
+def is_moderator() -> Any:
+ """|deco|
+
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
+
+ This guards adds a predicate which prevents any chatter from using a command
+ who does not possess the ``Moderator`` badge.
+
+ See also, :func:`~.commands.is_elevated` for a guard to allow the ``broadcaster``, any ``moderator`` or ``VIP`` chatter
+ to use the command.
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ return context.chatter.moderator
+
+ return guard(predicate)
+
+
+def is_vip() -> Any:
+ """|deco|
+
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
+
+ This guards adds a predicate which prevents any chatter from using a command who does not possess the ``VIP`` badge.
+
+ .. note::
+
+ Due to a Twitch limitation, moderators and broadcasters can not be VIPs, another guard has been made to help aid
+ in allowing these members to also be seen as VIP, see: :func:`~.commands.is_elevated`.
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ return context.chatter.vip
+
+ return guard(predicate)
+
+
+def is_elevated() -> Any:
+ """|deco|
+
+ A decorator which adds a :func:`~.commands.guard` to a :class:`~.commands.Command`.
+
+ This guards adds a predicate which prevents any chatter from using a command who does not posses one or more of the
+ folowing badges: ``broadcaster``, ``moderator`` or ``VIP``.
+
+ .. important::
+
+ The chatter only needs **1** of the badges to pass the guard.
+
+ Example
+ -------
+
+ .. code:: python3
+
+ # This command can be run by anyone with broadcaster, moderator OR VIP status...
+
+ @commands.is_elevated()
+ @commands.command()
+ async def test(self, ctx: commands.Context) -> None:
+ await ctx.reply("You are allowed to use this command!")
+
+ Raises
+ ------
+ GuardFailure
+ The guard predicate returned ``False`` and prevented the chatter from using the command.
+ """
+
+ def predicate(context: Context) -> bool:
+ chatter: Chatter = context.chatter
+ return chatter.moderator or chatter.vip
+
+ return guard(predicate)
+
+
+def cooldown(*, base: type[BaseCooldown] = Cooldown, key: KeyT = BucketType.chatter, **kwargs: Any) -> Any:
+ """|deco|
+
+ A decorator which adds a :class:`~.commands.Cooldown` to a :class:`~.Command`.
+
+ The parameters of this decorator may change depending on the class passed to the ``base`` parameter.
+ The parameters needed for the default built-in classes are listed instead.
+
+ When a command is on cooldown or ratelimited, the :exc:`~.commands.CommandOnCooldown` exception is raised and propagated to all
+ error handlers.
+
+ Parameters
+ ----------
+ base: :class:`~.commands.BaseCooldown`
+ Optional base class to use to construct the cooldown. By default this is the :class:`~.commands.Cooldown` class, which
+ implements a ``Token Bucket Algorithm``. Another option is the :class:`~.commands.GCRACooldown` class which implements
+ the Generic Cell Rate Algorithm, which can be thought of as similar to a continuous state leaky-bucket algorithm, but
+ instead of updating internal state, calculates a Theoretical Arrival Time (TAT), making it more performant,
+ and dissallowing short bursts of requests. However before choosing a class, consider reading more information on the
+ differences between the ``Token Bucket`` and ``GCRA``.
+
+ A custom class which inherits from :class:`~.commands.BaseCooldown` could also be used. All ``keyword-arguments``
+ passed to this decorator, minus ``base`` and ``key`` will also be passed to the constructor of the cooldown base class.
+
+ Useful if you would like to implement your own ratelimiting algorithm.
+ key: Callable[[Any], Hashable] | Callable[[Any], Coroutine[Any, Any, Hashable]] | :class:`~.commands.BucketType`
+ A regular or coroutine function, or :class:`~.commands.BucketType` which must return a :class:`typing.Hashable`
+ used to determine the keys for the cooldown.
+
+ The :class:`~.commands.BucketType` implements some default strategies. If your function returns ``None`` the cooldown
+ will be bypassed. See below for some examples. By default the key is :attr:`~.commands.BucketType.chatter`.
+ rate: int
+ An ``int`` indicating how many times a command should be allowed ``per`` x amount of time. Note the relevance and
+ effects of both ``rate`` and ``per`` change slightly between algorithms.
+ per: float | datetime.timedelta
+ A ``float`` or :class:`datetime.timedelta` indicating the length of the time (as seconds) a cooldown window is open.
+
+ E.g. if ``rate`` is ``2`` and ``per`` is ``60.0``, using the default :class:`~.commands.Cooldown` class, you will only
+ be able to send ``two`` commands ``per 60 seconds``, with the window starting when you send the first command.
+
+ Examples
+ --------
+
+ Using the default :class:`~.commands.Cooldown` to allow the command to be ran twice by an individual chatter, every 10 seconds.
+
+ .. code:: python3
+
+ @commands.command()
+ @commands.cooldown(rate=2, per=10, key=commands.BucketType.chatter)
+ async def hello(ctx: commands.Context) -> None:
+ ...
+
+ Using a custom key to bypass cooldowns for certain users.
+
+ .. code:: python3
+
+ def bypass_cool(ctx: commands.Context) -> typing.Hashable | None:
+ # Returning None will bypass the cooldown
+
+ if ctx.chatter.name.startswith("cool"):
+ return None
+
+ # For everyone else, return and call the default chatter strategy
+ # This strategy returns a tuple of (channel/broadcaster.id, chatter.id) to use as the unique key
+ return commands.BucketType.chatter(ctx)
+
+ @commands.command()
+ @commands.cooldown(rate=2, per=10, key=bypass_cool)
+ async def hello(ctx: commands.Context) -> None:
+ ...
+
+ Using a custom function to implement dynamic keys.
+
+ .. code:: python3
+
+ async def custom_key(ctx: commands.Context) -> typing.Hashable | None:
+ # As an example, get some user info from a database with the chatter...
+ # This is just to showcase a use for an async version of a custom key...
+ ...
+
+ # Example column in database...
+ if row["should_bypass_cooldown"]:
+ return None
+
+ # Note: Returing chatter.id is equivalent to commands.BucketType.user NOT commands.BucketType.chatter
+ # which uses the channel ID and User ID together as the key...
+ return ctx.chatter.id
+
+ @commands.command()
+ @commands.cooldown(rate=1, per=300, key=custom_key)
+ async def hello(ctx: commands.Context) -> None:
+ ...
+ """
+ bucket_: Bucket[Context] = Bucket.from_cooldown(base=base, key=key, **kwargs)
+
+ def wrapper(func: Any) -> Any:
+ nonlocal bucket_
-def cooldown(rate, per, bucket=Bucket.default):
- def decorator(func: FN) -> FN:
if isinstance(func, Command):
- func._cooldowns.append(Cooldown(rate, per, bucket))
+ func._buckets.append(bucket_)
else:
- func.__cooldowns__ = [Cooldown(rate, per, bucket)]
- return func
+ try:
+ func.__command_cooldowns__.append(bucket_)
+ except AttributeError:
+ func.__command_cooldowns__ = [bucket_]
+
+ return func # type: ignore
+
+ return wrapper
+
+
+class _CaseInsensitiveDict(dict[str, VT]):
+ def __contains__(self, key: object) -> bool:
+ return super().__contains__(key.casefold()) if isinstance(key, str) else False
+
+ def __delitem__(self, key: str) -> None:
+ return super().__delitem__(key.casefold())
+
+ def __getitem__(self, key: str) -> VT:
+ return super().__getitem__(key.casefold())
+
+ @overload
+ def get(self, key: str, /) -> VT | None: ...
+
+ @overload
+ def get(self, key: str, default: VT, /) -> VT: ...
+
+ @overload
+ def get(self, key: str, default: DT, /) -> VT | DT: ...
+
+ def get(self, key: str, default: DT = None, /) -> VT | DT:
+ return super().get(key.casefold(), default)
+
+ @overload
+ def pop(self, key: str, /) -> VT: ...
+
+ @overload
+ def pop(self, key: str, default: VT, /) -> VT: ...
+
+ @overload
+ def pop(self, key: str, default: DT, /) -> VT | DT: ...
+
+ def pop(self, key: str, default: DT = MISSING, /) -> VT | DT:
+ if default is MISSING:
+ return super().pop(key.casefold())
+
+ return super().pop(key.casefold(), default)
- return decorator
+ def __setitem__(self, key: str, value: VT) -> None:
+ super().__setitem__(key.casefold(), value)
diff --git a/twitchio/ext/commands/errors.py b/twitchio/ext/commands/errors.py
deleted file mode 100644
index d22d64f2..00000000
--- a/twitchio/ext/commands/errors.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from __future__ import annotations
-
-from typing import Optional, TYPE_CHECKING
-
-if TYPE_CHECKING:
- from .core import Command
-
-
-class TwitchCommandError(Exception):
- """Base TwitchIO Command Error. All command errors derive from this error."""
-
- pass
-
-
-class InvalidCogMethod(TwitchCommandError):
- pass
-
-
-class InvalidCog(TwitchCommandError):
- pass
-
-
-class MissingRequiredArgument(TwitchCommandError):
- def __init__(self, *args, argname: Optional[str] = None) -> None:
- self.name: str = argname or "unknown"
-
- if args:
- super().__init__(*args)
- else:
- super().__init__(f"Missing required argument `{self.name}`")
-
-
-class BadArgument(TwitchCommandError):
- def __init__(self, message: str, argname: Optional[str] = None):
- self.name: str = argname # type: ignore # this'll get fixed in the parser handler
- self.message = message
- super().__init__(message)
-
-
-class ArgumentParsingFailed(BadArgument):
- def __init__(
- self, message: str, original: Exception, argname: Optional[str] = None, expected: Optional[type] = None
- ):
- self.original: Exception = original
- self.name: str = argname # type: ignore # in theory this'll never be None but if someone is creating this themselves itll be none.
- self.expected_type: Optional[type] = expected
-
- Exception.__init__(self, message) # bypass badArgument
-
-
-class UnionArgumentParsingFailed(ArgumentParsingFailed):
- def __init__(self, argname: str, expected: tuple[type, ...]):
- self.name: str = argname
- self.expected_type: tuple[type, ...] = expected
-
- self.message = f"Failed to convert argument `{self.name}` to any of the valid options"
- Exception.__init__(self, self.message)
-
-
-class CommandNotFound(TwitchCommandError):
- def __init__(self, message: str, name: str) -> None:
- self.name: str = name
- super().__init__(message)
-
-
-class CommandOnCooldown(TwitchCommandError):
- def __init__(self, command: Command, retry_after: float):
- self.command: Command = command
- self.retry_after: float = retry_after
- super().__init__(f"Command <{command.name}> is on cooldown. Try again in ({retry_after:.2f})s")
-
-
-class CheckFailure(TwitchCommandError):
- pass
diff --git a/twitchio/ext/commands/exceptions.py b/twitchio/ext/commands/exceptions.py
new file mode 100644
index 00000000..8fdd5cf1
--- /dev/null
+++ b/twitchio/ext/commands/exceptions.py
@@ -0,0 +1,203 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+import inspect
+from typing import Any
+
+from twitchio.exceptions import TwitchioException
+
+from .cooldowns import BaseCooldown
+
+
+__all__ = (
+ "ArgumentError",
+ "BadArgument",
+ "CommandError",
+ "CommandExistsError",
+ "CommandHookError",
+ "CommandInvokeError",
+ "CommandNotFound",
+ "CommandOnCooldown",
+ "ComponentLoadError",
+ "ConversionError",
+ "GuardFailure",
+ "InputError",
+ "MissingRequiredArgument",
+ "ModuleAlreadyLoadedError",
+ "ModuleLoadFailure",
+ "ModuleNotLoadedError",
+ "NoEntryPointError",
+ "PrefixError",
+)
+
+
+class CommandError(TwitchioException):
+ """Base exception for command related errors.
+
+ All commands.ext related exceptions inherit from this class.
+ """
+
+
+class ComponentLoadError(CommandError):
+ """Exception raised when a :class:`.commands.Component` fails to load."""
+
+
+class CommandInvokeError(CommandError):
+ """Exception raised when an error occurs during invocation of a command.
+
+ Attributes
+ ----------
+ original: :class:`Exception` | None
+ The original exception that caused this error. Could be None.
+ """
+
+ def __init__(self, msg: str | None = None, original: Exception | None = None) -> None:
+ self.original: Exception | None = original
+ super().__init__(msg)
+
+
+class CommandHookError(CommandInvokeError): ...
+
+
+class CommandNotFound(CommandError):
+ """Exception raised when a message is processed with a valid prefix and no :class:`~twitchio.ext.commands.Command`
+ could be found.
+ """
+
+
+class CommandExistsError(CommandError):
+ """Exception raised when you try to add a command or alias to a command that is already registered on the
+ :class:`~twitchio.ext.commands.Bot`."""
+
+
+class PrefixError(CommandError):
+ """Exception raised when invalid prefix or prefix callable is passed."""
+
+
+class InputError(CommandError):
+ """Base exception for errors raised while parsing the input for command invocation. All :class:`ArgumentError` and
+ child exception inherit from this class."""
+
+
+class ArgumentError(InputError):
+ """Base exception for errors raised while parsing arguments in commands."""
+
+
+class UnexpectedQuoteError(ArgumentError):
+ def __init__(self, quote: str) -> None:
+ self.quote: str = quote
+ super().__init__(f"Unexpected quote mark, {quote!r}, in non-quoted string")
+
+
+class InvalidEndOfQuotedStringError(ArgumentError):
+ def __init__(self, char: str) -> None:
+ self.char: str = char
+ super().__init__(f"Expected space after closing quotation but received {char!r}")
+
+
+class ExpectedClosingQuoteError(ArgumentError):
+ def __init__(self, close_quote: str) -> None:
+ self.close_quote: str = close_quote
+ super().__init__(f"Expected closing {close_quote}.")
+
+
+class GuardFailure(CommandError):
+ """Exception raised when a :func:`~.commands.guard` fails or blocks a command from executing.
+
+ This exception should be subclassed when raising a custom exception for a :func:`~twitchio.ext.commands.guard`.
+ """
+
+ def __init__(self, msg: str | None = None, *, guard: Any | None = None) -> None:
+ self.guard: Any | None = guard
+ super().__init__(msg or "")
+
+
+class ConversionError(ArgumentError):
+ """Base exception for conversion errors which occur during argument parsing in commands."""
+
+
+class BadArgument(ConversionError):
+ """Exception raised when a parsing or conversion failure is encountered on an argument to pass into a command."""
+
+ def __init__(self, msg: str, *, name: str | None = None, value: str | None) -> None:
+ self.name: str | None = name
+ self.value: str | None = value
+ super().__init__(msg)
+
+
+class MissingRequiredArgument(ArgumentError):
+ """Exception raised when parsing a command and a parameter that is required is not encountered."""
+
+ def __init__(self, param: inspect.Parameter) -> None:
+ self.param: inspect.Parameter = param
+ super().__init__(f'"{param.name}" is a required argument which is missing.')
+
+
+class ModuleError(TwitchioException):
+ """Base exception for module related errors."""
+
+
+class ModuleLoadFailure(ModuleError):
+ """Exception raised when a module failed to load during execution or `setup` entry point."""
+
+ def __init__(self, name: str, exc: Exception) -> None:
+ super().__init__(name, exc)
+
+
+class NoEntryPointError(ModuleError):
+ """Exception raised when the module does not have a `setup` entry point coroutine."""
+
+ def __init__(self, msg: str) -> None:
+ super().__init__(msg)
+
+
+class ModuleAlreadyLoadedError(ModuleError):
+ """Exception raised when a module has already been loaded."""
+
+ def __init__(self, msg: str) -> None:
+ super().__init__(msg)
+
+
+class ModuleNotLoadedError(ModuleError):
+ """Exception raised when a module was not loaded."""
+
+ def __init__(self, msg: str) -> None:
+ super().__init__(msg)
+
+
+class CommandOnCooldown(GuardFailure):
+ """Exception raised when a command is invoked while on cooldown/ratelimited.
+
+ Attributes
+ ----------
+ cooldown: :class:`~.commands.BaseCooldown`
+ The specific cooldown instance used that raised this error.
+ remaining: :class:`float`
+ The time remaining for the cooldown as a :class:`float` of seconds.
+ """
+
+ def __init__(self, msg: str | None = None, *, cooldown: BaseCooldown, remaining: float) -> None:
+ self.cooldown: BaseCooldown = cooldown
+ self.remaining: float = remaining
+ super().__init__(msg or f"Cooldown is ratelimited. Try again in {remaining} seconds.")
diff --git a/twitchio/ext/commands/meta.py b/twitchio/ext/commands/meta.py
deleted file mode 100644
index 48e59e46..00000000
--- a/twitchio/ext/commands/meta.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from __future__ import annotations
-import inspect
-import types
-from functools import partial
-from typing import Callable, Dict, List
-
-from .core import Command, Context
-
-
-__all__ = ("Cog",)
-
-
-class CogEvent:
- def __init__(self, *, name: str, func: Callable, module: str):
- self.name = name
- self.func = func
- self.module = module
-
-
-class CogMeta(type):
- def __new__(mcs, *args, **kwargs):
- name, bases, attrs = args
- attrs["__cogname__"] = kwargs.pop("name", name)
-
- self = super().__new__(mcs, name, bases, attrs, **kwargs)
- self._events = {}
- self._commands = {}
-
- for name, mem in inspect.getmembers(self):
- if isinstance(mem, (CogEvent, Command)) and name.startswith(("cog_", "bot_")): # Invalid method prefixes
- raise RuntimeError(f'The event or command "{name}" starts with an invalid prefix (cog_ or bot_).')
-
- if isinstance(mem, CogEvent):
- try:
- self._events[mem.name].append(mem.func)
- except KeyError:
- self._events[mem.name] = [mem.func]
-
- return self
-
-
-class Cog(metaclass=CogMeta):
- """Class used for creating a TwitchIO Cog.
-
- Cogs help organise code and provide powerful features for creating bots.
- Cogs can contain commands, events and special cog specific methods to help with checks,
- before and after command invocation hooks, and cog error handlers.
-
- To use a cog simply subclass Cog and add it. Once added, cogs can be un-added and re-added live.
-
- Examples
- ----------
-
- .. code:: py
-
- # In modules/test.py
-
- from twitchio.ext import commands
-
-
- class MyCog(commands.Cog):
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command()
- async def hello(self, ctx: commands.Context):
- await ctx.send(f"Hello, {ctx.author.name}!")
-
- @commands.Cog.event()
- async def event_message(self, message):
- # An event inside a cog!
- if message.echo:
- return
-
- print(message.content)
-
-
- def prepare(bot: commands.Bot):
- # Load our cog with this module...
- bot.add_cog(MyCog(bot))
- """
-
- _commands: Dict[str, Command]
- _events: Dict[str, List[Callable]]
-
- def _load_methods(self, bot) -> None:
- for name, method in inspect.getmembers(self):
- if isinstance(method, Command):
- method._instance = self
- method.cog = self
-
- self._commands[method.name] = method
- bot.add_command(method)
-
- events = self._events.copy()
- self._events = {}
-
- for event, callbacks in events.items():
- for callback in callbacks:
- callback = partial(callback, self)
- bot.add_event(callback=callback, name=event)
-
- def _unload_methods(self, bot) -> None:
- for name in self._commands:
- bot.remove_command(name)
-
- for event, callbacks in self._events.items():
- for callback in callbacks:
- bot.remove_event(callback=callback)
-
- self._events = {}
-
- try:
- self.cog_unload()
- except Exception:
- pass
-
- @classmethod
- def event(cls, event: str = None) -> Callable[[types.FunctionType], CogEvent]:
- """Add an event listener to this Cog.
-
- Examples
- ----------
-
- .. code:: py
-
- class MyCog(commands.Cog):
-
- def __init__(...):
- ...
-
- @commands.Cog.event()
- async def event_message(self, message: twitchio.Message):
- print(message.content)
-
- @commands.Cog.event("event_ready")
- async def bot_is_ready(self):
- print('Bot is ready!')
- """
-
- def decorator(func) -> CogEvent:
- event_name = event or func.__name__
-
- return CogEvent(name=event_name, func=func, module=cls.__module__)
-
- return decorator
-
- @property
- def name(self) -> str:
- """This cogs name."""
- return self.__cogname__ # type: ignore
-
- @property
- def commands(self) -> dict:
- """The commands associated with this cog as a mapping."""
- return self._commands # type: ignore
-
- async def cog_error(self, exception: Exception) -> None:
- pass
-
- async def cog_command_error(self, ctx: Context, exception: Exception) -> None:
- """Method invoked when an error is raised in one of this cogs commands.
-
- Parameters
- -------------
- ctx: :class:`Context`
- The context around the invoked command.
- exception: Exception
- The exception raised.
- """
- pass
-
- async def cog_check(self, ctx: Context) -> bool:
- """A cog-wide check which is ran everytime a command from this Cog is invoked.
-
- Parameters
- ------------
- ctx: :class:`Context`
- The context used to try and invoke this command.
-
- Notes
- -------
- .. note::
-
- This method must return True/False or raise. If this check returns False or raises, it will fail
- and an exception will be propagated to error handlers.
- """
- return True
-
- def cog_unload(self) -> None:
- pass
diff --git a/twitchio/ext/commands/stringparser.py b/twitchio/ext/commands/stringparser.py
deleted file mode 100644
index ea3faebd..00000000
--- a/twitchio/ext/commands/stringparser.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-from __future__ import annotations
-from typing import Dict
-
-
-class StringParser:
- def __init__(self):
- self.count = 0
- self.index = 0
- self.eof = 0
- self.start = 0
- self.words: Dict[int, str] = {}
- self.ignore = False
-
- def process_string(self, msg: str) -> Dict[int, str]:
- while self.count < len(msg):
- loc = msg[self.count]
-
- if loc == '"' and not self.ignore:
- self.ignore = True
- self.start = self.count + 1
-
- elif loc == '"' and self.ignore:
- self.words[self.index] = msg[self.start : self.count]
- self.index += 1
- self.ignore = False
- self.start = self.count + 1
-
- elif loc.isspace() and not self.ignore:
- if self.start != self.count:
- self.words[self.index] = msg[self.start : self.count]
- self.index += 1
-
- self.start = self.count + 1
-
- self.count += 1
-
- if self.start < len(msg) and not self.ignore:
- self.words[self.index] = msg[self.start : len(msg)].strip()
-
- return self.words
-
- def copy(self) -> StringParser:
- new = self.__class__()
- new.count = self.count
- new.start = self.start
- new.words = self.words.copy()
- new.index = self.index
- new.ignore = self.ignore
- return new
diff --git a/twitchio/ext/commands/types_.py b/twitchio/ext/commands/types_.py
new file mode 100644
index 00000000..bc57071c
--- /dev/null
+++ b/twitchio/ext/commands/types_.py
@@ -0,0 +1,50 @@
+"""
+MIT License
+
+Copyright (c) 2017 - Present PythonistaGuild
+
+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.
+"""
+
+from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
+
+from twitchio.types_.options import ClientOptions
+
+
+if TYPE_CHECKING:
+ from .components import Component
+
+
+Component_T = TypeVar("Component_T", bound="Component | None")
+
+
+class CommandOptions(TypedDict, total=False):
+ aliases: list[str]
+ extras: dict[Any, Any]
+ guards_after_parsing: bool
+ cooldowns_before_guards: bool
+
+
+class ComponentOptions(TypedDict, total=False):
+ name: str | None
+ extras: dict[Any, Any]
+
+
+class BotOptions(ClientOptions, total=False):
+ case_insensitive: bool
diff --git a/twitchio/ext/commands/utils.py b/twitchio/ext/commands/utils.py
deleted file mode 100644
index f6d38987..00000000
--- a/twitchio/ext/commands/utils.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from typing import Any, TypeVar, Optional
-
-K = TypeVar("K", bound=str)
-V = TypeVar("V")
-
-
-class _CaseInsensitiveDict(dict):
- def __getitem__(self, key: K) -> V:
- return super().__getitem__(key.lower())
-
- def __setitem__(self, key: K, value: V) -> None:
- super().__setitem__(key.lower(), value)
-
- def __delitem__(self, key: K) -> None:
- return super().__delitem__(key.lower())
-
- def __contains__(self, key: K) -> bool: # type: ignore
- return super().__contains__(key.lower())
-
- def get(self, key: K, default: Any = None) -> Optional[V]:
- return super().get(key, default)
-
- def pop(self, key: K, default: Any = None) -> V:
- return super().pop(key, default)
diff --git a/twitchio/ext/commands/view.py b/twitchio/ext/commands/view.py
new file mode 100644
index 00000000..dff3db16
--- /dev/null
+++ b/twitchio/ext/commands/view.py
@@ -0,0 +1,195 @@
+"""
+The MIT License (MIT)
+
+Copyright (c) 2015-present Rapptz
+
+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.
+"""
+
+from __future__ import annotations
+
+from .exceptions import ExpectedClosingQuoteError, InvalidEndOfQuotedStringError, UnexpectedQuoteError
+
+
+# map from opening quotes to closing quotes
+_quotes = {
+ '"': '"',
+ "‘": "’",
+ "‚": "‛",
+ "“": "”",
+ "„": "‟",
+ "⹂": "⹂",
+ "「": "」",
+ "『": "』",
+ "〝": "〞",
+ "﹁": "﹂",
+ "﹃": "﹄",
+ """: """,
+ "「": "」",
+ "«": "»",
+ "‹": "›",
+ "《": "》",
+ "〈": "〉",
+}
+_all_quotes = set(_quotes.keys()) | set(_quotes.values())
+
+
+class StringView:
+ def __init__(self, buffer: str) -> None:
+ self.index: int = 0
+ self.buffer: str = buffer
+ self.end: int = len(buffer)
+ self.previous = 0
+
+ @property
+ def current(self) -> str | None:
+ return None if self.eof else self.buffer[self.index]
+
+ @property
+ def eof(self) -> bool:
+ return self.index >= self.end
+
+ def undo(self) -> None:
+ self.index = self.previous
+
+ def skip_ws(self) -> bool:
+ pos = 0
+ while not self.eof:
+ try:
+ current = self.buffer[self.index + pos]
+ if not current.isspace():
+ break
+ pos += 1
+ except IndexError:
+ break
+
+ self.previous = self.index
+ self.index += pos
+ return self.previous != self.index
+
+ def skip_string(self, string: str) -> bool:
+ strlen = len(string)
+ if self.buffer[self.index : self.index + strlen] == string:
+ self.previous = self.index
+ self.index += strlen
+ return True
+ return False
+
+ def read_rest(self) -> str:
+ result = self.buffer[self.index :]
+ self.previous = self.index
+ self.index = self.end
+ return result
+
+ def read(self, n: int) -> str:
+ result = self.buffer[self.index : self.index + n]
+ self.previous = self.index
+ self.index += n
+ return result
+
+ def get(self) -> str | None:
+ try:
+ result = self.buffer[self.index + 1]
+ except IndexError:
+ result = None
+
+ self.previous = self.index
+ self.index += 1
+ return result
+
+ def get_word(self) -> str:
+ pos = 0
+ while not self.eof:
+ try:
+ current = self.buffer[self.index + pos]
+ if current.isspace():
+ break
+ pos += 1
+ except IndexError:
+ break
+ self.previous: int = self.index
+ result = self.buffer[self.index : self.index + pos]
+ self.index += pos
+ return result
+
+ def get_quoted_word(self) -> str | None:
+ current = self.current
+ if current is None:
+ return None
+
+ close_quote = _quotes.get(current)
+ is_quoted = bool(close_quote)
+ if is_quoted:
+ result = []
+ _escaped_quotes = (current, close_quote)
+ else:
+ result = [current]
+ _escaped_quotes = _all_quotes
+
+ while not self.eof:
+ current = self.get()
+ if not current:
+ if is_quoted:
+ # unexpected EOF
+ raise ExpectedClosingQuoteError(close_quote)
+ return "".join(result)
+
+ # currently we accept strings in the format of "hello world"
+ # to embed a quote inside the string you must escape it: "a \"world\""
+ if current == "\\":
+ next_char = self.get()
+ if not next_char:
+ # string ends with \ and no character after it
+ if is_quoted:
+ # if we're quoted then we're expecting a closing quote
+ raise ExpectedClosingQuoteError(close_quote)
+ # if we aren't then we just let it through
+ return "".join(result)
+
+ if next_char in _escaped_quotes:
+ # escaped quote
+ result.append(next_char)
+ else:
+ # different escape character, ignore it
+ self.undo()
+ result.append(current)
+ continue
+
+ if not is_quoted and current in _all_quotes:
+ # we aren't quoted
+ raise UnexpectedQuoteError(current)
+
+ # closing quote
+ if is_quoted and current == close_quote:
+ next_char = self.get()
+ valid_eof = not next_char or next_char.isspace()
+ if not valid_eof:
+ raise InvalidEndOfQuotedStringError(next_char) # type: ignore # this will always be a string
+
+ # we're quoted so it's okay
+ return "".join(result)
+
+ if current.isspace() and not is_quoted:
+ # end of word found
+ return "".join(result)
+
+ result.append(current)
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/twitchio/ext/eventsub/__init__.py b/twitchio/ext/eventsub/__init__.py
deleted file mode 100644
index 563f55fe..00000000
--- a/twitchio/ext/eventsub/__init__.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-2021 TwitchIO
-
-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.
-"""
-
-from .server import EventSubClient
-from .websocket import EventSubWSClient, Websocket
-from .models import *
diff --git a/twitchio/ext/eventsub/http.py b/twitchio/ext/eventsub/http.py
deleted file mode 100644
index e7b92a2f..00000000
--- a/twitchio/ext/eventsub/http.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from __future__ import annotations
-from typing import TYPE_CHECKING, Tuple, Dict, Type, Union, Optional
-from ...http import Route
-
-from . import models
-
-if TYPE_CHECKING:
- from .server import EventSubClient
- from .websocket import EventSubWSClient
- from .models import EventData, Subscription
-
-__all__ = ("EventSubHTTP",)
-
-
-class EventSubHTTP:
- def __init__(self, client: Union[EventSubClient, EventSubWSClient], token: Optional[str]):
- self._client = client
- self._http = client.client._http
- self._token = token
-
- async def create_webhook_subscription(
- self, event_type: Tuple[str, int, Type[EventData]], condition: Dict[str, str]
- ):
- payload = {
- "type": event_type[0],
- "version": str(event_type[1]),
- "condition": condition,
- "transport": {"method": "webhook", "callback": self._client.route, "secret": self._client.secret},
- }
- route = Route("POST", "eventsub/subscriptions", body=payload, token=self._token)
- return await self._http.request(route, paginate=False, force_app_token=True)
-
- async def create_websocket_subscription(
- self, event_type: Tuple[str, int, Type[EventData]], condition: Dict[str, str], session_id: str, token: str
- ) -> dict:
- payload = {
- "type": event_type[0],
- "version": str(event_type[1]),
- "condition": condition,
- "transport": {"method": "websocket", "session_id": session_id},
- }
- route = Route("POST", "eventsub/subscriptions", body=payload, token=token)
- return await self._http.request(route, paginate=False, full_body=True) # type: ignore
-
- async def delete_subscription(self, subscription: Union[str, Subscription]):
- if isinstance(subscription, models.Subscription):
- return await self._http.request(
- Route("DELETE", "eventsub/subscriptions", query=[("id", subscription.id)]), paginate=False
- )
- return await self._http.request(
- Route("DELETE", "eventsub/subscriptions", query=[("id", subscription)]), paginate=False
- )
-
- async def get_subscriptions(
- self, status: Optional[str] = None, sub_type: Optional[str] = None, user_id: Optional[int] = None
- ):
- qs = []
- if status:
- qs.append(("status", status))
- if sub_type:
- qs.append(("type", sub_type))
- if user_id:
- qs.append(("user_id", str(user_id)))
- if len(qs) > 1:
- raise ValueError("You cannot specify more than one filter.")
-
- return [
- models.Subscription(d)
- for d in await self._http.request(Route("GET", "eventsub/subscriptions", query=qs), paginate=False)
- ]
-
- async def get_status(self, status: str = None):
- qs = []
- if status:
- qs.append(("status", status))
-
- v = await self._http.request(Route("GET", "eventsub/subscriptions", query=qs), paginate=False, full_body=True)
- del v["data"]
- del v["pagination"]
- return v
diff --git a/twitchio/ext/eventsub/models.py b/twitchio/ext/eventsub/models.py
deleted file mode 100644
index 5e44da19..00000000
--- a/twitchio/ext/eventsub/models.py
+++ /dev/null
@@ -1,2452 +0,0 @@
-from __future__ import annotations
-import datetime
-import hmac
-import hashlib
-import logging
-from enum import Enum
-from typing import Any, Dict, TYPE_CHECKING, Optional, Type, Union, Tuple, List, overload
-from typing_extensions import Literal
-
-from aiohttp import web
-
-from twitchio import PartialUser, parse_timestamp as _parse_datetime
-
-if TYPE_CHECKING:
- from .server import EventSubClient
- from .websocket import EventSubWSClient
-
-try:
- import ujson as json
-
- def _loads(s: str) -> dict:
- return json.loads(s)
-
-except ModuleNotFoundError:
- import json
-
- def _loads(s: str) -> dict:
- return json.loads(s)
-
-
-logger = logging.getLogger("twitchio.ext.eventsub")
-
-
-class EmptyObject:
- def __init__(self, **kwargs):
- self.__dict__.update(kwargs)
-
-
-class Subscription:
- __slots__ = "id", "status", "type", "version", "cost", "condition", "transport", "transport_method", "created_at"
-
- def __init__(self, data: dict):
- self.id: str = data["id"]
- self.status: str = data["status"]
- self.type: str = data["type"]
- self.version = int(data["version"])
- self.cost: int = data["cost"]
- self.condition: Dict[str, str] = data["condition"]
- self.created_at = _parse_datetime(data["created_at"])
- self.transport = EmptyObject()
- self.transport_method: TransportType = getattr(TransportType, data["transport"]["method"])
- self.transport.method: str = data["transport"]["method"] # type: ignore
-
- if self.transport_method is TransportType.webhook:
- self.transport.callback: str = data["transport"]["callback"] # type: ignore
- else:
- self.transport.callback: str = "" # type: ignore # compatibility
- self.transport.session_id: str = data["transport"]["session_id"] # type: ignore
-
-
-class Headers:
- """
- The headers of the inbound EventSub message
-
- Attributes
- -----------
- message_id: :class:`str`
- The unique ID of the message
- message_retry: :class:`int`
- Unknown
- signature: :class:`str`
- The signature associated with the message
- subscription_type: :class:`str`
- The type of the subscription on the inbound message
- subscription_version: :class:`str`
- The version of the subscription.
- timestamp: :class:`datetime.datetime`
- The timestamp the message was sent at
- """
-
- def __init__(self, request: web.Request):
- self.message_id: str = request.headers["Twitch-Eventsub-Message-Id"]
- self.message_retry: int = int(request.headers["Twitch-Eventsub-Message-Retry"])
- self.message_type: str = request.headers["Twitch-Eventsub-Message-Type"]
- self.signature: str = request.headers["Twitch-Eventsub-Message-Signature"]
- self.subscription_type: str = request.headers["Twitch-Eventsub-Subscription-Type"]
- self.subscription_version: str = request.headers["Twitch-Eventsub-Subscription-Version"]
- self.timestamp = _parse_datetime(request.headers["Twitch-Eventsub-Message-Timestamp"])
- self._raw_timestamp = request.headers["Twitch-Eventsub-Message-Timestamp"]
-
-
-class WebsocketHeaders:
- """
- The headers of the inbound Websocket EventSub message
-
- Attributes
- -----------
- message_id: :class:`str`
- The unique ID of the message
- message_type: :class:`str`
- The type of the message coming through
- message_retry: :class:`int`
- Kept for compatibility with :class:`Headers`
- signature: :class:`str`
- Kept for compatibility with :class:`Headers`
- subscription_type: :class:`str`
- The type of the subscription on the inbound message
- subscription_version: :class:`str`
- The version of the subscription.
- timestamp: :class:`datetime.datetime`
- The timestamp the message was sent at
- """
-
- def __init__(self, frame: dict):
- meta = frame["metadata"]
- self.message_id: str = meta["message_id"]
- self.timestamp = _parse_datetime(meta["message_timestamp"])
- self.message_type: Literal["notification", "revocation", "reconnect", "session_keepalive"] = meta[
- "message_type"
- ]
- self.message_retry: int = 0 # don't make breaking changes with the Header class
- self.signature: str = ""
- self.subscription_type: Optional[str]
- self.subscription_version: Optional[str]
- if frame["payload"]:
- self.subscription_type = frame["payload"]["subscription"]["type"]
- self.subscription_version = frame["payload"]["subscription"]["version"]
- else:
- self.subscription_type = None
- self.subscription_version = None
-
-
-class BaseEvent:
- """
- The base of all the event classes
-
- Attributes
- -----------
- subscription: Optional[:class:`Subscription`]
- The subscription attached to the message. This is only optional when using the websocket eventsub transport
- headers: :class`Headers`
- The headers received with the message
- """
-
- __slots__ = ("_client", "_raw_data", "subscription", "headers")
-
- @overload
- def __init__(self, client: EventSubClient, _data: str, request: web.Request):
- ...
-
- @overload
- def __init__(self, client: EventSubWSClient, _data: dict, request: None):
- ...
-
- def __init__(
- self, client: Union[EventSubClient, EventSubWSClient], _data: Union[str, dict], request: Optional[web.Request]
- ):
- self._client = client
- self._raw_data = _data
-
- if isinstance(_data, str):
- data: dict = _loads(_data)
- else:
- data = _data
-
- self.headers: Union[Headers, WebsocketHeaders]
- self.subscription: Optional[Subscription]
-
- if request:
- data: dict = _loads(_data)
- self.headers = Headers(request)
- self.subscription = Subscription(data["subscription"])
- self.setup(data)
- else:
- self.headers = WebsocketHeaders(data)
- if data["payload"]:
- self.subscription = Subscription(data["payload"]["subscription"])
- else:
- self.subscription = None
- self.setup(data["payload"])
-
- def setup(self, data: dict):
- pass
-
- def verify(self):
- """
- Only used in webhook transport types. Verifies the message is valid
- """
- hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8") # type: ignore
- secret = self._client.secret.encode("utf-8")
- digest = hmac.new(secret, msg=hmac_message, digestmod=hashlib.sha256).hexdigest()
-
- if not hmac.compare_digest(digest, self.headers.signature[7:]):
- logger.warning(f"Recieved a message with an invalid signature, discarding.")
- return web.Response(status=400)
-
- return web.Response(status=200)
-
-
-class RevokationEvent(BaseEvent):
- pass
-
-
-class ChallengeEvent(BaseEvent):
- """
- A challenge event.
-
- .. note::
- These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubClient`
-
- Attributes
- -----------
- challenge: :class`str`
- The challenge received from twitch
- """
-
- __slots__ = ("challenge",)
-
- def setup(self, data: dict):
- self.challenge: str = data["challenge"]
-
- def verify(self):
- hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8") # type: ignore
- secret = self._client.secret.encode("utf-8")
- digest = hmac.new(secret, msg=hmac_message, digestmod=hashlib.sha256).hexdigest()
-
- if not hmac.compare_digest(digest, self.headers.signature[7:]):
- logger.warning(f"Recieved a message with an invalid signature, discarding.")
- return web.Response(status=400)
-
- return web.Response(status=200, text=self.challenge)
-
-
-class ReconnectEvent(BaseEvent):
- """
- A reconnect event. Called by twitch when the websocket needs to be disconnected for maintenance or other reasons
-
- .. note::
- These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubWSClient`
-
- Attributes
- -----------
- reconnect_url: :class:`str`
- The URL to reconnect to
- connected_at: :class:`datetime.datetime`
- When the original websocket connected
- """
-
- __slots__ = ("reconnect_url", "connected_at")
-
- def __init__(
- self, client: Union[EventSubClient, EventSubWSClient], _data: Union[str, dict], request: Optional[web.Request]
- ):
- # we skip the super init here because reconnect events dont have headers or subscription information
-
- self._client = client
- self._raw_data = _data
-
- if isinstance(_data, str):
- data: dict = _loads(_data)
- else:
- data = _data
-
- self.setup(data["payload"])
-
- def setup(self, data: dict):
- self.reconnect_url: str = data["session"]["reconnect_url"]
- self.connected_at: datetime.datetime = _parse_datetime(data["session"]["connected_at"])
-
-
-class KeepAliveEvent(BaseEvent):
- """
- A keep-alive event. Called by twitch when no message has been sent for more than ``keepalive_timeout``
-
- .. note::
- These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubWSClient`
-
- """
-
- pass
-
-
-class NotificationEvent(BaseEvent):
- """
- A notification event
-
- Attributes
- -----------
- data: :class:`models._DataType`
- The data associated with this event
- """
-
- __slots__ = ("data",)
-
- def setup(self, _data: dict):
- data: dict = _data["event"]
- typ = self.subscription.type
- if typ not in SubscriptionTypes._type_map:
- raise ValueError(f"Unexpected subscription type '{typ}'")
-
- self.data: _DataType = SubscriptionTypes._type_map[typ](self._client, data)
-
-
-def _transform_user(client: EventSubClient, data: dict, field: str) -> PartialUser:
- return client.client.create_user(int(data[field + "_id"]), data[field + "_name"])
-
-
-class EventData:
- __slots__ = ()
-
-
-class ChannelBanData(EventData):
- """
- A Ban event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that was banned
- broadcaster: :class:`twitchio.PartialUser`
- The broadcaster who's channel the ban occurred in
- moderator: :class:`twitchio.PartialUser`
- The moderator responsible for the ban
- reason: :class:`str`
- The reason for the ban
- ends_at: Optional[:class:`datetime.datetime`]
- When the ban ends at. Could be ``None``
- permanant: :class:`bool`
- A typo of ``permanent`` Kept for backwards compatibility
- permanent: :class:`bool`
- Whether the ban is permanent
- """
-
- __slots__ = "user", "broadcaster", "moderator", "reason", "ends_at", "permenant", "permanent"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.moderator = _transform_user(client, data, "moderator_user")
- self.reason: str = data["reason"]
- self.ends_at: Optional[datetime.datetime] = data["ends_at"] and _parse_datetime(data["ends_at"])
- self.permenant: bool = data["is_permanent"]
- self.permanent = self.permenant # fix the spelling while keeping backwards compat
-
-
-class ChannelSubscribeData(EventData):
- """
- A Subscription event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user who subscribed
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was subscribed to
- tier: :class:`int`
- The tier of the subscription
- is_gift: :class:`bool`
- Whether the subscription was a gift or not
- """
-
- __slots__ = "user", "broadcaster", "tier", "is_gift"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.tier = int(data["tier"])
- self.is_gift: bool = data["is_gift"]
-
-
-class ChannelSubscriptionEndData(EventData):
- """
- A Subscription End event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user who subscribed
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was subscribed to
- tier: :class:`int`
- The tier of the subscription
- is_gift: :class:`bool`
- Whether the subscription was a gift or not
- """
-
- __slots__ = "user", "broadcaster", "tier", "is_gift"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.tier = int(data["tier"])
- self.is_gift: bool = data["is_gift"]
-
-
-class ChannelSubscriptionGiftData(EventData):
- """
- A Subscription Gift event
- Explicitly, the act of giving another user a Subscription.
- Receiving a gift-subscription uses ChannelSubscribeData above, with is_gift is ``True``
-
- Attributes
- -----------
- is_anonymous: :class:`bool`
- Whether the gift sub was anonymous
- user: Optional[:class:`twitchio.PartialUser`]
- The user that gifted subs. Will be ``None`` if ``is_anonymous`` is ``True``
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was subscribed to
- tier: :class:`int`
- The tier of the subscription
- total: :class:`int`
- The total number of subs gifted by a user at once
- cumulative_total: Optional[:class:`int`]
- The total number of subs gifted by a user overall. Will be ``None`` if ``is_anonymous`` is ``True``
- """
-
- __slots__ = "is_anonymous", "user", "broadcaster", "tier", "total", "cumulative_total"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.is_anonymous: bool = data["is_anonymous"]
- self.user: Optional[PartialUser] = None if self.is_anonymous else _transform_user(client, data, "user")
- self.broadcaster: Optional[PartialUser] = _transform_user(client, data, "broadcaster_user")
- self.tier = int(data["tier"])
- self.total = int(data["total"])
- self.cumulative_total: Optional[int] = None if self.is_anonymous else int(data["cumulative_total"])
-
-
-class ChannelSubscriptionMessageData(EventData):
- """
- A Subscription Message event.
- A combination of resubscriptions + the messages users type as part of the resub.
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user who subscribed
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was subscribed to
- tier: :class:`int`
- The tier of the subscription
- message: :class:`str`
- The user's resubscription message
- emote_data: :class:`list`
- emote data within the user's resubscription message. Not the emotes themselves
- cumulative_months: :class:`int`
- The total number of months a user has subscribed to the channel
- streak: Optional[:class:`int`]
- The total number of months subscribed in a row. ``None`` if the user declines to share it.
- duration: :class:`int`
- The length of the subscription. Typically 1, but some users may buy subscriptions for several months.
- """
-
- __slots__ = "user", "broadcaster", "tier", "message", "emote_data", "cumulative_months", "streak", "duration"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.tier = int(data["tier"])
- self.message: str = data["message"]["text"]
- self.emote_data: List[Dict] = data["message"].get("emotes", [])
- self.cumulative_months: int = data["cumulative_months"]
- self.streak: Optional[int] = data["streak_months"]
- self.duration: int = data["duration_months"]
-
-
-class ChannelCheerData(EventData):
- """
- A Cheer event
-
- Attributes
- ----------
- is_anonymous: :class:`bool`
- Whether the cheer was anonymous
- user: Optional[:class:`twitchio.PartialUser`]
- The user that cheered. Will be ``None`` if ``is_anonymous`` is ``True``
- broadcaster: :class:`twitchio.PartialUser`
- The channel the cheer happened on
- message: :class:`str`
- The message sent along with the bits
- bits: :class:`int`
- The amount of bits sent
- """
-
- __slots__ = "user", "broadcaster", "is_anonymous", "message", "bits"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.is_anonymous: bool = data["is_anonymous"]
- self.user: Optional[PartialUser] = _transform_user(client, data, "user") if not self.is_anonymous else None
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.message: str = data["message"]
- self.bits = int(data["bits"])
-
-
-class ChannelUpdateData(EventData):
- """
- A Channel Update event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was updated
- title: :class:`str`
- The title of the stream
- language: :class:`str`
- The language of the channel
- category_id: :class:`str`
- The category the stream is in
- category_name: :class:`str`
- The category the stream is in
- is_mature: :class:`bool`
- Whether the channel is marked as mature by the broadcaster
- """
-
- __slots__ = "broadcaster", "title", "language", "category_id", "category_name", "is_mature"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.title: str = data["title"]
- self.language: str = data["language"]
- self.category_id: str = data["category_id"]
- self.category_name: str = data["category_name"]
- self.is_mature: bool = data["is_mature"] == "true"
-
-
-class ChannelUnbanData(EventData):
- """
- A Channel Unban event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that was unbanned
- broadcaster: :class:`twitchio.PartialUser`
- The channel the unban occurred in
- moderator: :class`twitchio.PartialUser`
- The moderator that preformed the unban
- """
-
- __slots__ = "user", "broadcaster", "moderator"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.moderator = _transform_user(client, data, "moderator_user")
-
-
-class ChannelFollowData(EventData):
- """
- A Follow event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that followed
- broadcaster: :class:`twitchio.PartialUser`
- The channel that was followed
- followed_at: :class:`datetime.datetime`
- When the follow occurred
- """
-
- __slots__ = "user", "broadcaster", "followed_at"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.followed_at = _parse_datetime(data["followed_at"])
-
-
-class ChannelRaidData(EventData):
- """
- A Raid event
-
- Attributes
- -----------
- raider: :class:`twitchio.PartialUser`
- The person initiating the raid
- reciever: :class:`twitchio.PartialUser`
- The person recieving the raid
- viewer_count: :class:`int`
- The amount of people raiding
- """
-
- __slots__ = "raider", "reciever", "viewer_count"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.raider = _transform_user(client, data, "from_broadcaster_user")
- self.reciever = _transform_user(client, data, "to_broadcaster_user")
- self.viewer_count: int = data["viewers"]
-
-
-class ChannelModeratorAddRemoveData(EventData):
- """
- A Moderator Add/Remove event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user being added or removed from the moderator status
- broadcaster: :class:`twitchio.PartialUser`
- The channel that is having a moderator added/removed
- """
-
- __slots__ = "broadcaster", "user"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
-
-
-class CustomReward:
- """
- A Custom Reward
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel that has this reward
- id: :class:`str`
- The ID of the reward
- title: :class:`str`
- The title of the reward
- cost: :class:`int`
- The cost of the reward in Channel Points
- prompt: :class:`str`
- The prompt of the reward
- enabled: Optional[:class:`bool`]
- Whether or not the reward is enabled. Will be `None` for Redemption events.
- paused: Optional[:class:`bool`]
- Whether or not the reward is paused. Will be `None` for Redemption events.
- in_stock: Optional[:class:`bool`]
- Whether or not the reward is in stock. Will be `None` for Redemption events.
- cooldown_until: Optional[:class:`datetime.datetime`]
- How long until the reward is off cooldown and can be redeemed again. Will be `None` for Redemption events.
- input_required: Optional[:class:`bool`]
- Whether or not the reward requires an input. Will be `None` for Redemption events.
- redemptions_skip_queue: Optional[:class:`bool`]
- Whether or not redemptions for this reward skips the queue. Will be `None` for Redemption events.
- redemptions_current_stream: Optional[:class:`int`]
- How many redemptions of this reward have been redeemed for this stream. Will be `None` for Redemption events.
- max_per_stream: Tuple[:class:`bool`, :class:`int`]
- Whether or not a per-stream redemption limit is in place, and if so, the maximum number of redemptions allowed
- per stream. Will be `None` for Redemption events.
- max_per_user_per_stream: Tuple[:class:`bool`, :class:`int`]
- Whether or not a per-user-per-stream redemption limit is in place, and if so, the maximum number of redemptions
- allowed per user per stream. Will be `None` for Redemption events.
- cooldown: Tuple[:class:`bool`, :class:`int`]
- Whether or not a global cooldown is in place, and if so, the number of seconds until the reward can be redeemed
- again. Will be `None` for Redemption events.
- background_color: Optional[:class:`str`]
- Hexadecimal color code for the background of the reward.
- image: Optional[:class:`str`]
- Image URL for the reward.
- """
-
- __slots__ = (
- "broadcaster",
- "id",
- "title",
- "cost",
- "prompt",
- "enabled",
- "paused",
- "in_stock",
- "cooldown_until",
- "input_required",
- "redemptions_skip_queue",
- "redemptions_current_stream",
- "max_per_stream",
- "max_per_user_stream",
- "cooldown",
- "background_color",
- "image",
- )
-
- def __init__(self, data, broadcaster):
- self.broadcaster: PartialUser = broadcaster
-
- self.id: str = data["id"]
-
- self.title: str = data["title"]
- self.cost: int = data["cost"]
- self.prompt: str = data["prompt"]
-
- self.enabled: Optional[bool] = data.get("is_enabled", None)
- self.paused: Optional[bool] = data.get("is_paused", None)
- self.in_stock: Optional[bool] = data.get("is_in_stock", None)
-
- self.cooldown_until: Optional[datetime.datetime] = (
- _parse_datetime(data["cooldown_expires_at"]) if data.get("cooldown_expires_at", None) else None
- )
-
- self.input_required: Optional[bool] = data.get("is_user_input_required", None)
- self.redemptions_skip_queue: Optional[bool] = data.get("should_redemptions_skip_request_queue", None)
- self.redemptions_current_stream: Optional[bool] = data.get("redemptions_redeemed_current_stream", None)
-
- self.max_per_stream: Tuple[Optional[bool], Optional[int]] = (
- data.get("max_per_stream", {}).get("is_enabled"),
- data.get("max_per_stream", {}).get("value"),
- )
- self.max_per_user_stream: Tuple[Optional[bool], Optional[int]] = (
- data.get("max_per_user_per_stream", {}).get("is_enabled"),
- data.get("max_per_user_per_stream", {}).get("value"),
- )
- self.cooldown: Tuple[Optional[bool], Optional[int]] = (
- data.get("global_cooldown", {}).get("is_enabled"),
- data.get("global_cooldown", {}).get("seconds"),
- )
-
- self.background_color: Optional[str] = data.get("background_color", None)
- self.image: Optional[str] = data.get("image", data.get("default_image", {})).get("url_1x", None)
-
-
-class CustomRewardAddUpdateRemoveData(EventData):
- """
- A Custom Reward Add/Update/Remove event
-
- Attributes
- -----------
- id: :class:`str`
- The ID of the custom reward
- broadcaster: :class:`twitchio.PartialUser`
- The channel the custom reward was modified in
- reward: :class:`CustomReward`
- The reward object
- """
-
- __slots__ = "reward", "broadcaster", "id"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.id: str = data["id"]
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.reward = CustomReward(data, self.broadcaster)
-
-
-class CustomRewardRedemptionAddUpdateData(EventData):
- """
- A Custom Reward Redemption event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the redemption occurred in
- user: :class:`twitchio.PartialUser`
- The user that redeemed the reward
- id: :class:`str`
- The ID of the redemption
- input: :class:`str`
- The user input, if present. This will be an empty string if it is not present
- status: :class:`str`
- One of "unknown", "unfulfilled", "fulfilled", or "cancelled"
- redeemed_at: :class:`datetime.datetime`
- When the reward was redeemed at
- reward: :class:`CustomReward`
- The reward object
- """
-
- __slots__ = "broadcaster", "id", "user", "input", "status", "reward", "redeemed_at"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.user = _transform_user(client, data, "user")
- self.id: str = data["id"]
- self.input: str = data["user_input"]
- self.status: Literal["unknown", "unfulfilled", "fulfilled", "cancelled"] = data["status"]
- self.redeemed_at = _parse_datetime(data["redeemed_at"])
- self.reward = CustomReward(data["reward"], self.broadcaster)
-
-
-class HypeTrainContributor:
- """
- A Contributor to a Hype Train
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user
- type: :class:`str`
- One of "bits, "subscription" or "other". The way they contributed to the hype train
- total: :class:`int`
- How many points they've contributed to the Hype Train
- """
-
- __slots__ = "user", "type", "total"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.type: Literal["bits", "subscription", "other"] = data["type"] # one of bits, subscription
- self.total: int = data["total"]
-
-
-class HypeTrainBeginProgressData(EventData):
- """
- A Hype Train Begin/Progress event
-
- Attributes
- -----------
-
- broadcaster: :class:`twitchio.PartialUser`
- The channel the Hype Train occurred in
- total_points: :class:`int`
- The total amounts of points in the Hype Train
- progress: :class:`int`
- The progress of the Hype Train towards the next level
- goal: :class:`int`
- The goal to reach the next level
- started: :class:`datetime.datetime`
- When the Hype Train started
- expires: :class:`datetime.datetime`
- When the Hype Train ends
- top_contributions: List[:class:`HypeTrainContributor`]
- The top contributions of the Hype Train
- last_contribution: :class:`HypeTrainContributor`
- The last contributor to the Hype Train
- level: :class:`int`
- The current level of the Hype Train
- """
-
- __slots__ = (
- "broadcaster",
- "total_points",
- "progress",
- "goal",
- "top_contributions",
- "last_contribution",
- "started",
- "expires",
- "level",
- )
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.total_points: int = data["total"]
- self.progress: int = data["progress"]
- self.goal: int = data["goal"]
- self.started = _parse_datetime(data["started_at"])
- self.expires = _parse_datetime(data["expires_at"])
- self.top_contributions = [HypeTrainContributor(client, d) for d in data["top_contributions"]]
- self.last_contribution = HypeTrainContributor(client, data["last_contribution"])
- self.level: int = data["level"]
-
-
-class HypeTrainEndData(EventData):
- """
- A Hype Train End event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the Hype Train occurred in
- total_points: :class:`int`
- The total amounts of points in the Hype Train
- level: :class:`int`
- The level the hype train reached
- started: :class:`datetime.datetime`
- When the Hype Train started
- top_contributions: List[:class:`HypeTrainContributor`]
- The top contributions of the Hype Train
- cooldown_ends_at: :class:`datetime.datetime`
- When another Hype Train can begin
- """
-
- __slots__ = "broadcaster", "level", "total_points", "top_contributions", "started", "ended", "cooldown_ends_at"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.total_points: int = data["total"]
- self.level: int = data["level"]
- self.started = _parse_datetime(data["started_at"])
- self.ended = _parse_datetime(data["ended_at"])
- self.cooldown_ends_at = _parse_datetime(data["cooldown_ends_at"])
- self.top_contributions = [HypeTrainContributor(client, d) for d in data["top_contributions"]]
-
-
-class PollChoice:
- """
- A Poll Choice
-
- Attributes
- -----------
- choice_id: :class:`str`
- The ID of the choice
- title: :class:`str`
- The title of the choice
- bits_votes: :class:`int`
- How many votes were cast using Bits
-
- .. warning::
-
- Twitch have removed support for voting with bits.
- This will return as 0
-
- channel_points_votes: :class:`int`
- How many votes were cast using Channel Points
- votes: :class:`int`
- The total number of votes, including votes cast using Bits and Channel Points
- """
-
- __slots__ = "choice_id", "title", "bits_votes", "channel_points_votes", "votes"
-
- def __init__(self, data):
- self.choice_id: str = data["id"]
- self.title: str = data["title"]
- self.bits_votes: int = data.get("bits_votes", 0)
- self.channel_points_votes: int = data.get("channel_points_votes", 0)
- self.votes: int = data.get("votes", 0)
-
-
-class BitsVoting:
- """
- Information on voting on a poll with Bits
-
- Attributes
- -----------
- is_enabled: :class:`bool`
- Whether users can use Bits to vote on the poll
- amount_per_vote: :class:`int`
- How many Bits are required to cast an extra vote
-
- .. warning::
-
- Twitch have removed support for voting with bits.
- This will return as False and 0 respectively
-
- """
-
- __slots__ = "is_enabled", "amount_per_vote"
-
- def __init__(self, data):
- self.is_enabled: bool = data["is_enabled"]
- self.amount_per_vote: int = data["amount_per_vote"]
-
-
-class ChannelPointsVoting:
- """
- Information on voting on a poll with Channel Points
-
- Attributes
- -----------
- is_enabled: :class:`bool`
- Whether users can use Channel Points to vote on the poll
- amount_per_vote: :class:`int`
- How many Channel Points are required to cast an extra vote
- """
-
- __slots__ = "is_enabled", "amount_per_vote"
-
- def __init__(self, data):
- self.is_enabled: bool = data["is_enabled"]
- self.amount_per_vote: int = data["amount_per_vote"]
-
-
-class PollStatus(Enum):
- """
- The status of a poll.
-
- ACTIVE: Poll is currently in progress.
- COMPLETED: Poll has reached its `ended_at` time.
- TERMINATED: Poll has been manually terminated before its `ended_at` time.
- ARCHIVED: Poll is no longer visible on the channel.
- MODERATED: Poll is no longer visible to any user on Twitch.
- INVALID: Something went wrong determining the state.
- """
-
- ACTIVE = "active"
- COMPLETED = "completed"
- TERMINATED = "terminated"
- ARCHIVED = "archived"
- MODERATED = "moderated"
- INVALID = "invalid"
-
-
-class PollBeginProgressData(EventData):
- """
- A Poll Begin/Progress event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the poll occured in
- poll_id: :class:`str`
- The ID of the poll
- title: :class:`str`
- The title of the poll
- choices: List[:class:`PollChoice`]
- The choices in the poll
- bits_voting: :class:`BitsVoting`
- Information on voting on the poll with Bits
-
- .. warning::
-
- Twitch have removed support for voting with bits.
-
- channel_points_voting: :class:`ChannelPointsVoting`
- Information on voting on the poll with Channel Points
- started_at: :class:`datetime.datetime`
- When the poll started
- ends_at: :class:`datetime.datetime`
- When the poll is set to end
- ...
- """
-
- __slots__ = (
- "broadcaster",
- "poll_id",
- "title",
- "choices",
- "bits_voting",
- "channel_points_voting",
- "started_at",
- "ends_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.poll_id: str = data["id"]
- self.title: str = data["title"]
- self.choices = [PollChoice(c) for c in data["choices"]]
- self.bits_voting = BitsVoting(data["bits_voting"])
- self.channel_points_voting = ChannelPointsVoting(data["channel_points_voting"])
- self.started_at = _parse_datetime(data["started_at"])
- self.ends_at = _parse_datetime(data["ends_at"])
-
-
-class PollEndData(EventData):
- """
- A Poll End event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the poll occured in
- poll_id: :class:`str`
- The ID of the poll
- title: :class:`str`
- The title of the poll
- choices: List[:class:`PollChoice`]
- The choices in the poll
- bits_voting: :class:`BitsVoting`
- Information on voting on the poll with Bits
-
- .. warning::
-
- Twitch have removed support for voting with bits.
-
- channel_points_voting: :class:`ChannelPointsVoting`
- Information on voting on the poll with Channel Points
- status: :class:`PollStatus`
- How the poll ended
- started_at: :class:`datetime.datetime`
- When the poll started
- ended_at: :class:`datetime.datetime`
- When the poll is set to end
- """
-
- __slots__ = (
- "broadcaster",
- "poll_id",
- "title",
- "choices",
- "bits_voting",
- "channel_points_voting",
- "status",
- "started_at",
- "ended_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.poll_id: str = data["id"]
- self.title: str = data["title"]
- self.choices = [PollChoice(c) for c in data["choices"]]
- self.bits_voting = BitsVoting(data["bits_voting"])
- self.channel_points_voting = ChannelPointsVoting(data["channel_points_voting"])
- self.status = PollStatus(data["status"].lower())
- self.started_at = _parse_datetime(data["started_at"])
- self.ended_at = _parse_datetime(data["ended_at"])
-
-
-class Predictor:
- """
- A Predictor
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user who predicted an outcome
- channel_points_used: :class:`int`
- How many Channel Points the user used to predict this outcome
- channel_points_won: :class:`int`
- How many Channel Points was distributed to the user.
- Will be `None` if the Prediction is unresolved, cancelled (refunded), or the user predicted the losing outcome.
- """
-
- __slots__ = "user", "channel_points_used", "channel_points_won"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.user = _transform_user(client, data, "user")
- self.channel_points_used: int = data["channel_points_used"]
- self.channel_points_won: int = data["channel_points_won"]
-
-
-class PredictionOutcome:
- """
- A Prediction Outcome
-
- Attributes
- -----------
- outcome_id: :class:`str`
- The ID of the outcome
- title: :class:`str`
- The title of the outcome
- channel_points: :class:`int`
- The amount of Channel Points that have been bet for this outcome
- color: :class:`str`
- The color of the outcome. Can be `blue` or `pink`
- users: :class:`int`
- The number of users who predicted the outcome
- top_predictors: List[:class:`Predictor`]
- The top predictors of the outcome
- """
-
- __slots__ = "outcome_id", "title", "channel_points", "color", "users", "top_predictors"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.outcome_id: str = data["id"]
- self.title: str = data["title"]
- self.channel_points: int = data.get("channel_points", 0)
- self.color: str = data["color"]
- self.users: int = data.get("users", 0)
- self.top_predictors = [Predictor(client, x) for x in data.get("top_predictors", [])]
-
- @property
- def colour(self) -> str:
- """The colour of the prediction. Alias to color."""
- return self.color
-
-
-class PredictionStatus(Enum):
- """
- The status of a Prediction.
-
- ACTIVE: Prediction is active and viewers can make predictions.
- LOCKED: Prediction has been locked and viewers can no longer make predictions.
- RESOLVED: A winning outcome has been chosen and the Channel Points have been distributed to the users who guessed the correct outcome.
- CANCELED: Prediction has been canceled and the Channel Points have been refunded to participants.
- """
-
- ACTIVE = "active"
- LOCKED = "locked"
- RESOLVED = "resolved"
- CANCELED = "canceled"
-
-
-class PredictionBeginProgressData(EventData):
- """
- A Prediction Begin/Progress event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the prediction occured in
- prediction_id: :class:`str`
- The ID of the prediction
- title: :class:`str`
- The title of the prediction
- outcomes: List[:class:`PredictionOutcome`]
- The outcomes for the prediction
- started_at: :class:`datetime.datetime`
- When the prediction started
- locks_at: :class:`datetime.datetime`
- When the prediction is set to be locked
- """
-
- __slots__ = "broadcaster", "prediction_id", "title", "outcomes", "started_at", "locks_at"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.prediction_id: str = data["id"]
- self.title: str = data["title"]
- self.outcomes = [PredictionOutcome(client, x) for x in data["outcomes"]]
- self.started_at = _parse_datetime(data["started_at"])
- self.locks_at = _parse_datetime(data["locks_at"])
-
-
-class PredictionLockData(EventData):
- """
- A Prediction Begin/Progress event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the prediction occured in
- prediction_id: :class:`str`
- The ID of the prediction
- title: :class:`str`
- The title of the prediction
- outcomes: List[:class:`PredictionOutcome`]
- The outcomes for the prediction
- started_at: :class:`datetime.datetime`
- When the prediction started
- locked_at: :class:`datetime.datetime`
- When the prediction was locked
- """
-
- __slots__ = "broadcaster", "prediction_id", "title", "outcomes", "started_at", "locked_at"
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.prediction_id: str = data["id"]
- self.title: str = data["title"]
- self.outcomes = [PredictionOutcome(client, x) for x in data["outcomes"]]
- self.started_at = _parse_datetime(data["started_at"])
- self.locked_at = _parse_datetime(data["locked_at"])
-
-
-class PredictionEndData(EventData):
- """
- A Prediction Begin/Progress event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel the prediction occured in
- prediction_id: :class:`str`
- The ID of the prediction
- title: :class:`str`
- The title of the prediction
- winning_outcome_id: :class:`str`
- The ID of the outcome that won
- outcomes: List[:class:`PredictionOutcome`]
- The outcomes for the prediction
- status: :class:`PredictionStatus`
- How the prediction ended
- started_at: :class:`datetime.datetime`
- When the prediction started
- ended_at: :class:`datetime.datetime`
- When the prediction ended
- """
-
- __slots__ = (
- "broadcaster",
- "prediction_id",
- "title",
- "winning_outcome_id",
- "outcomes",
- "status",
- "started_at",
- "ended_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict):
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.prediction_id: str = data["id"]
- self.title: str = data["title"]
- self.winning_outcome_id: str = data["winning_outcome_id"]
- self.outcomes = [PredictionOutcome(client, x) for x in data["outcomes"]]
- self.status = PredictionStatus(data["status"].lower())
- self.started_at = _parse_datetime(data["started_at"])
- self.ended_at = _parse_datetime(data["ended_at"])
-
-
-class StreamOnlineData(EventData):
- """
- A Stream Start event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel that went live
- id: :class:`str`
- Some sort of ID for the stream
- type: :class:`str`
- One of "live", "playlist", "watch_party", "premier", or "rerun". The type of live event.
- started_at: :class:`datetime.datetime`
- """
-
- __slots__ = "broadcaster", "id", "type", "started_at"
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.id: str = data["id"]
- self.type: Literal["live", "playlist", "watch_party", "premier", "rerun"] = data["type"]
- self.started_at = _parse_datetime(data["started_at"])
-
-
-class StreamOfflineData(EventData):
- """
- A Stream End event
-
- Attributes
- -----------
- broadcaster: :class:`twitchio.PartialUser`
- The channel that stopped streaming
- """
-
- __slots__ = ("broadcaster",)
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
-
-
-class UserAuthorizationGrantedData(EventData):
- """
- An Authorization Granted event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that has granted authorization for your app
- client_id: :class:`str`
- The client id of the app that had its authorization granted
- """
-
- __slots__ = "client_id", "user"
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user = _transform_user(client, data, "user")
- self.client_id: str = data["client_id"]
-
-
-class UserAuthorizationRevokedData(EventData):
- """
- An Authorization Revokation event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that has revoked authorization for your app
- client_id: :class:`str`
- The client id of the app that had its authorization revoked
- """
-
- __slots__ = "client_id", "user"
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user = _transform_user(client, data, "user")
- self.client_id: str = data["client_id"]
-
-
-class UserUpdateData(EventData):
- """
- A User Update event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user that was updated
- email: Optional[:class:`str`]
- The users email, if you have permission to read this information
- description: :class:`str`
- The channels description (displayed as ``bio``)
- """
-
- __slots__ = "user", "email", "description"
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user = _transform_user(client, data, "user")
- self.email: Optional[str] = data["email"]
- self.description: str = data["description"]
-
-
-class ChannelGoalBeginProgressData(EventData):
- """
- A goal begin event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The broadcaster that started the goal
- id : :class:`str`
- The ID of the goal event
- type: :class:`str`
- The goal type
- description: :class:`str`
- The goal description
- current_amount: :class:`int`
- The goal current amount
- target_amount: :class:`int`
- The goal target amount
- started_at: :class:`datetime.datetime`
- The datetime the goal was started
- """
-
- __slots__ = "user", "id", "type", "description", "current_amount", "target_amount", "started_at"
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user = _transform_user(client, data, "broadcaster_user")
- self.id: str = data["id"]
- self.type: str = data["type"]
- self.description: str = data["description"]
- self.current_amount: int = data["current_amount"]
- self.target_amount: int = data["target_amount"]
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
-
-
-class ChannelGoalEndData(EventData):
- """
- A goal end event
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The broadcaster that ended the goal
- id : :class:`str`
- The ID of the goal event
- type: :class:`str`
- The goal type
- description: :class:`str`
- The goal description
- is_achieved: :class:`bool`
- Whether the goal is achieved
- current_amount: :class:`int`
- The goal current amount
- target_amount: :class:`int`
- The goal target amount
- started_at: :class:`datetime.datetime`
- The datetime the goal was started
- ended_at: :class:`datetime.datetime`
- The datetime the goal was ended
- """
-
- __slots__ = (
- "user",
- "id",
- "type",
- "description",
- "current_amount",
- "target_amount",
- "started_at",
- "is_achieved",
- "ended_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user = _transform_user(client, data, "broadcaster_user")
- self.id: str = data["id"]
- self.type: str = data["type"]
- self.description: str = data["description"]
- self.is_achieved: bool = data["is_achieved"]
- self.current_amount: int = data["current_amount"]
- self.target_amount: int = data["target_amount"]
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
- self.ended_at: datetime.datetime = _parse_datetime(data["ended_at"])
-
-
-class ChannelShieldModeBeginData(EventData):
- """
- Represents a Shield Mode activation status.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster whose Shield Mode status was updated.
- moderator: :class:`~twitchio.PartialUser`
- The moderator that updated the Shield Mode staus.
- started_at: :class:`datetime.datetime`
- The UTC datetime of when Shield Mode was last activated.
- """
-
- __slots__ = ("broadcaster", "moderator", "started_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
-
-
-class ChannelShieldModeEndData(EventData):
- """
- Represents a Shield Mode activation status.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster whose Shield Mode status was updated.
- moderator: :class:`~twitchio.PartialUser`
- The moderator that updated the Shield Mode staus.
- ended_at: :class:`datetime.datetime`
- The UTC datetime of when Shield Mode was last deactivated.
- """
-
- __slots__ = ("broadcaster", "moderator", "ended_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.ended_at: datetime.datetime = _parse_datetime(data["ended_at"])
-
-
-class ChannelShoutoutCreateData(EventData):
- """
- Represents a Shoutout event being sent.
-
- Requires the ``moderator:read:shoutouts`` or ``moderator:manage:shoutouts`` scope.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster from who sent the shoutout event.
- moderator: :class:`~twitchio.PartialUser`
- The moderator who sent the shoutout event.
- to_broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster who the shoutout was sent to.
- started_at: :class:`datetime.datetime`
- The datetime the shoutout was sent.
- viewer_count: :class:`int`
- The viewer count at the time of the shoutout
- cooldown_ends_at: :class:`datetime.datetime`
- The datetime the broadcaster can send another shoutout.
- target_cooldown_ends_at: :class:`datetime.datetime`
- The datetime the broadcaster can send another shoutout to the same broadcaster.
- """
-
- __slots__ = (
- "broadcaster",
- "moderator",
- "to_broadcaster",
- "started_at",
- "viewer_count",
- "cooldown_ends_at",
- "target_cooldown_ends_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.to_broadcaster: PartialUser = _transform_user(client, data, "to_broadcaster_user")
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
- self.viewer_count: int = data["viewer_count"]
- self.cooldown_ends_at: datetime.datetime = _parse_datetime(data["cooldown_ends_at"])
- self.target_cooldown_ends_at: datetime.datetime = _parse_datetime(data["target_cooldown_ends_at"])
-
-
-class ChannelShoutoutReceiveData(EventData):
- """
- Represents a Shoutout event being received.
-
- Requires the ``moderator:read:shoutouts`` or ``moderator:manage:shoutouts`` scope.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster receiving shoutout event.
- from_broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster who sent the shoutout.
- started_at: :class:`datetime.datetime`
- The datetime the shoutout was sent.
- viewer_count: :class:`int`
- The viewer count at the time of the shoutout
- """
-
- __slots__ = ("broadcaster", "from_broadcaster", "started_at", "viewer_count")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.from_broadcaster: PartialUser = _transform_user(client, data, "to_broadcaster_user")
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
- self.viewer_count: int = data["viewer_count"]
-
-
-class ChannelCharityDonationData(EventData):
- """
- Represents a donation towards a charity campaign.
-
- Requires the ``channel:read:charity`` scope.
-
- Attributes
- -----------
- id: :class:`str`
- The ID of the event.
- campaign_id: :class:`str`
- The ID of the running charity campaign.
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster running the campaign.
- user: :class:`~twitchio.PartialUser`
- The user who donated.
- charity_name: :class:`str`
- The name of the charity.
- charity_description: :class:`str`
- The description of the charity.
- charity_logo: :class:`str`
- The logo of the charity.
- charity_website: :class:`str`
- The websiet of the charity.
- donation_value: :class:`int`
- The amount of money being donated.
- donation_decimal_places: :class:`int`
- The decimal places to put into the :attr`~.donation_amount`.
- donation_currency: :class:`str`
- The currency that was donated (ex. ``USD``, ``GBP``, ``EUR``)
- """
-
- __slots__ = (
- "id",
- "campaign_id",
- "broadcaster",
- "user",
- "charity_name",
- "charity_description",
- "charity_logo",
- "charity_website",
- "donation_value",
- "donation_decimal_places",
- "donation_currency",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.id: str = data["id"]
- self.campaign_id: str = data["campaign_id"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.charity_name: str = data["charity_name"]
- self.charity_description: str = data["charity_description"]
- self.charity_logo: str = data["charity_logo"]
- self.charity_website: str = data["charity_website"]
- self.donation_value: int = data["amount"]["value"]
- self.donation_currency: str = data["amount"]["currency"]
- self.donation_decimal_places: int = data["amount"]["decimal_places"]
-
-
-class ChannelUnbanRequestCreateData(EventData):
- """
- Represents an unban request created by a user.
-
- Attributes
- -----------
- id: :class:`str`
- The ID of the ban request.
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster from which the user was banned.
- user: :class:`~twitchio.PartialUser`
- The user that was banned.
- text: :class:`str`
- The unban request text the user submitted.
- created_at: :class:`datetime.datetime`
- When the user submitted the request.
- """
-
- __slots__ = ("id", "broadcaster", "user", "text", "created_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.id: str = data["id"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.text: str = data["text"]
- self.created_at: datetime.datetime = _parse_datetime(data["created_at"])
-
-
-class ChannelUnbanRequestResolveData(EventData):
- """
- Represents an unban request that has been resolved by a moderator.
-
- Attributes
- -----------
- id: :class:`str`
- The ID of the ban request.
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster from which the user was banned.
- user: :class:`~twitchio.PartialUser`
- The user that was banned.
- moderator: :class:`~twitchio.PartialUser`
- The moderator that handled this unban request.
- resolution_text: :class:`str`
- The reasoning provided by the moderator.
- status: :class:`str`
- The resolution. either `accepted` or `denied`.
- """
-
- __slots__ = ("id", "broadcaster", "user", "text", "created_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.id: str = data["id"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.text: str = data["text"]
- self.created_at: datetime.datetime = _parse_datetime(data["created_at"])
-
-
-class AutomodMessageHoldData(EventData):
- """
- Represents a message being held by automod for manual review.
-
- Attributes
- ------------
- message_id: :class:`str`
- The ID of the message.
- message_content: :class:`str`
- The contents of the message
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster from which the message was held.
- user: :class:`~twitchio.PartialUser`
- The user that sent the message.
- level: :class:`int`
- The level of alarm raised for this message.
- category: :class:`str`
- The category of alarm that was raised for this message.
- created_at: :class:`datetime.datetime`
- When this message was held.
- message_fragments: :class:`dict`
- The fragments of this message. This includes things such as emotes and cheermotes. An example from twitch is provided:
-
- .. code:: json
-
- {
- "emotes": [
- {
- "text": "badtextemote1",
- "id": "emote-123",
- "set-id": "set-emote-1"
- },
- {
- "text": "badtextemote2",
- "id": "emote-234",
- "set-id": "set-emote-2"
- }
- ],
- "cheermotes": [
- {
- "text": "badtextcheermote1",
- "amount": 1000,
- "prefix": "prefix",
- "tier": 1
- }
- ]
- }
- """
-
- __slots__ = (
- "message_id",
- "message_content",
- "broadcaster",
- "user",
- "level",
- "category",
- "message_fragments",
- "created_at",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.message_id: str = data["message_id"]
- self.message_content: str = data["message"]
- self.message_fragments: Dict[str, Dict[str, Any]] = data["fragments"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.level: int = data["level"]
- self.category: str = data["category"]
- self.created_at: datetime.datetime = _parse_datetime(data["held_at"])
-
-
-class AutomodMessageUpdateData(EventData):
- """
- Represents a message that was updated by a moderator in the automod queue.
-
- Attributes
- ------------
- message_id: :class:`str`
- The ID of the message.
- message_content: :class:`str`
- The contents of the message
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster from which the message was held.
- user: :class:`~twitchio.PartialUser`
- The user that sent the message.
- moderator: :class:`~twitchio.PartialUser`
- The moderator that updated the message status.
- status: :class:`str`
- The new status of the message. Typically one of ``approved`` or ``denied``.
- level: :class:`int`
- The level of alarm raised for this message.
- category: :class:`str`
- The category of alarm that was raised for this message.
- created_at: :class:`datetime.datetime`
- When this message was held.
- message_fragments: :class:`dict`
- The fragments of this message. This includes things such as emotes and cheermotes. An example from twitch is provided:
-
- .. code:: json
-
- {
- "emotes": [
- {
- "text": "badtextemote1",
- "id": "emote-123",
- "set-id": "set-emote-1"
- },
- {
- "text": "badtextemote2",
- "id": "emote-234",
- "set-id": "set-emote-2"
- }
- ],
- "cheermotes": [
- {
- "text": "badtextcheermote1",
- "amount": 1000,
- "prefix": "prefix",
- "tier": 1
- }
- ]
- }
- """
-
- __slots__ = (
- "message_id",
- "message_content",
- "broadcaster",
- "user",
- "moderator",
- "level",
- "category",
- "message_fragments",
- "created_at",
- "status",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.message_id: str = data["message_id"]
- self.message_content: str = data["message"]
- self.message_fragments: Dict[str, Dict[str, Any]] = data["fragments"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.level: int = data["level"]
- self.category: str = data["category"]
- self.created_at: datetime.datetime = _parse_datetime(data["held_at"])
- self.status: str = data["status"]
-
-
-class AutomodSettingsUpdateData(EventData):
- """
- Represents a channels automod settings being updated.
-
- Attributes
- ------------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster for which the settings were updated.
- moderator: :class:`~twitchio.PartialUser`
- The moderator that updated the settings.
- overall :class:`int` | ``None``
- The overall level of automod aggressiveness.
- disability: :class:`int` | ``None``
- The aggression towards disability.
- aggression: :class:`int` | ``None``
- The aggression towards aggressive users.
- sex: :class:`int` | ``None``
- The aggression towards sexuality/gender.
- misogyny: :class:`int` | ``None``
- The aggression towards misogyny.
- bullying: :class:`int` | ``None``
- The aggression towards bullying.
- swearing: :class:`int` | ``None``
- The aggression towards cursing/language.
- race_religion: :class:`int` | ``None``
- The aggression towards race, ethnicity, and religion.
- sexual_terms: :class:`int` | ``None``
- The aggression towards sexual terms/references.
- """
-
- __slots__ = (
- "broadcaster",
- "moderator",
- "overall",
- "disability",
- "aggression",
- "sex",
- "misogyny",
- "bullying",
- "swearing",
- "race_religion",
- "sexual_terms",
- )
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.overall: Optional[int] = data["overall"]
- self.disability: Optional[int] = data["disability"]
- self.aggression: Optional[int] = data["aggression"]
- self.sex: Optional[int] = data["sex"]
- self.misogyny: Optional[int] = data["misogyny"]
- self.bullying: Optional[int] = data["bullying"]
- self.swearing: Optional[int] = data["swearing"]
- self.race_religion: Optional[int] = data["race_ethnicity_or_religion"]
- self.sexual_terms: Optional[int] = data["sex_based_terms"]
-
-
-class AutomodTermsUpdateData(EventData):
- """
- Represents a channels automod terms being updated.
-
- .. note::
-
- Private terms are not sent.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The broadcaster for which the terms were updated.
- moderator: :class:`~twitchio.PartialUser`
- The moderator who updated the terms.
- action: :class:`str`
- The action type.
- from_automod: :class:`bool`
- Whether the action was taken by automod.
- terms: List[:class:`str`]
- The terms that were applied.
- """
-
- __slots__ = ("broadcaster", "moderator", "action", "from_automod", "terms")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.action: str = data["action"]
- self.from_automod: bool = data["from_automod"]
- self.terms: List[str] = data["terms"]
-
-
-class ChannelModerateData(EventData):
- """
- Represents a channel moderation event.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The channel where the moderation event occurred.
- moderator: :class:`~twitchio.PartialUser`
- The moderator who performed the action.
- action: :class:`str`
- The action performed.
- followers: Optional[:class:`Followers`]
- Metadata associated with the followers command.
- slow: Optional[:class:`Slow`]
- Metadata associated with the slow command.
- vip: Optional[:class:`VIPStatus`]
- Metadata associated with the vip command.
- unvip: Optional[:class:`VIPStatus`]
- Metadata associated with the vip command.
- mod: Optional[:class:`ModeratorStatus`]
- Metadata associated with the mod command.
- unmod: Optional[:class:`ModeratorStatus`]
- Metadata associated with the mod command.
- ban: Optional[:class:`BanStatus`]
- Metadata associated with the ban command.
- unban: Optional[:class:`BanStatus`]
- Metadata associated with the unban command.
- timeout: Optional[:class:`TimeoutStatus`]
- Metadata associated with the timeout command.
- untimeout: Optional[:class:`TimeoutStatus`]
- Metadata associated with the untimeout command.
- raid: Optional[:class:`RaidStatus`]
- Metadata associated with the raid command.
- unraid: Optional[:class:`RaidStatus`]
- Metadata associated with the unraid command.
- delete: Optional[:class:`Delete`]
- Metadata associated with the delete command.
- automod_terms: Optional[:class:`AutoModTerms`]
- Metadata associated with the automod terms changes.
- unban_request: Optional[:class:`UnBanRequest`]
- Metadata associated with an unban request.
- """
-
- __slots__ = (
- "broadcaster",
- "moderator",
- "action",
- "followers",
- "slow",
- "vip",
- "unvip",
- "mod",
- "unmod",
- "ban",
- "unban",
- "timeout",
- "untimeout",
- "raid",
- "unraid",
- "delete",
- "automod_terms",
- "unban_request",
- )
-
- class Followers:
- """
- Metadata associated with the followers command.
-
- Attributes
- -----------
- follow_duration_minutes: :class:`int`
- The length of time, in minutes, that the followers must have followed the broadcaster to participate in the chat room.
- """
-
- def __init__(self, data: dict) -> None:
- self.follow_duration_minutes: int = data["follow_duration_minutes"]
-
- class Slow:
- """
- Metadata associated with the slow command.
-
- Attributes
- -----------
- wait_time_seconds: :class:`int`
- The amount of time, in seconds, that users need to wait between sending messages.
- """
-
- def __init__(self, data: dict) -> None:
- self.wait_time_seconds: int = data["wait_time_seconds"]
-
- class VIPStatus:
- """
- Metadata associated with the vip / unvip command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is gaining or losing VIP access.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
-
- class ModeratorStatus:
- """
- Metadata associated with the mod / unmod command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is gaining or losing moderator access.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
-
- class BanStatus:
- """
- Metadata associated with the ban / unban command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is banned / unbanned.
- reason: Optional[:class:`str`]
- Reason for the ban.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
- self.reason: Optional[str] = data.get("reason")
-
- class TimeoutStatus:
- """
- Metadata associated with the timeout / untimeout command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is timedout / untimedout.
- reason: Optional[:class:`str`]
- Reason for the timeout.
- expires_at: Optional[:class:`datetime.datetime`]
- Datetime the timeout expires.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
- self.reason: Optional[str] = data.get("reason")
- self.expires_at: Optional[datetime.datetime] = (
- _parse_datetime(data["expires_at"]) if data.get("expires_at") is not None else None
- )
-
- class RaidStatus:
- """
- Metadata associated with the raid / unraid command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is timedout / untimedout.
- viewer_count: :class:`int`
- The viewer count.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
- self.viewer_count: int = data["viewer_count"]
-
- class Delete:
- """
- Metadata associated with the delete command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is timedout / untimedout.
- message_id: :class:`str`
- The id of deleted message.
- message_body: :class:`str`
- The message body of the deleted message.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
- self.message_id: str = data["message_id"]
- self.message_body: str = data["message_body"]
-
- class AutoModTerms:
- """
- Metadata associated with the automod terms change.
-
- Attributes
- -----------
- action: :class:`Literal["add", "remove"]`
- Either “add” or “remove”.
- list: :class:`Literal["blocked", "permitted"]`
- Either “blocked” or “permitted”.
- terms: List[:class:`str`]
- Terms being added or removed.
- from_automod: :class:`bool`
- Whether the terms were added due to an Automod message approve/deny action.
- """
-
- def __init__(self, data: dict) -> None:
- self.action: Literal["add", "remove"] = data["action"]
- self.list: Literal["blocked", "permitted"] = data["list"]
- self.terms: List[str] = data["terms"]
- self.from_automod: bool = data["from_automod"]
-
- class UnBanRequest:
- """
- Metadata associated with the slow command.
-
- Attributes
- -----------
- user: :class:`~twitchio.PartialUser`
- The user who is requesting an unban.
- is_approved: :class:`bool`
- Whether or not the unban request was approved or denied.
- moderator_message: :class:`str`
- The message included by the moderator explaining their approval or denial.
- """
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.user: PartialUser = _transform_user(client, data, "user")
- self.is_approved: bool = data["is_approved"]
- self.moderator_message: str = data["moderator_message"]
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster = _transform_user(client, data, "broadcaster_user")
- self.moderator = _transform_user(client, data, "moderator_user")
- self.action: str = data["action"]
- self.followers = self.Followers(data["followers"]) if data.get("followers") is not None else None
- self.slow = self.Slow(data["slow"]) if data.get("slow") is not None else None
- self.vip = self.VIPStatus(client, data["vip"]) if data.get("vip") is not None else None
- self.unvip = self.VIPStatus(client, data["unvip"]) if data.get("unvip") is not None else None
- self.mod = self.ModeratorStatus(client, data["mod"]) if data.get("mod") is not None else None
- self.unmod = self.ModeratorStatus(client, data["unmod"]) if data.get("unmod") is not None else None
- self.ban = self.BanStatus(client, data["ban"]) if data.get("ban") is not None else None
- self.unban = self.BanStatus(client, data["unban"]) if data.get("unban") is not None else None
- self.timeout = self.TimeoutStatus(client, data["timeout"]) if data.get("timeout") is not None else None
- self.untimeout = self.TimeoutStatus(client, data["untimeout"]) if data.get("untimeout") is not None else None
- self.raid = self.RaidStatus(client, data["raid"]) if data.get("raid") is not None else None
- self.unraid = self.RaidStatus(client, data["unraid"]) if data.get("unraid") is not None else None
- self.delete = self.Delete(client, data["delete"]) if data.get("delete") is not None else None
- self.automod_terms = self.AutoModTerms(data["automod_terms"]) if data.get("automod_terms") is not None else None
- self.unban_request = (
- self.UnBanRequest(client, data["unban_request"]) if data.get("unban_request") is not None else None
- )
-
-
-class SuspiciousUserUpdateData(EventData):
- """
- Represents a suspicious user update event.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The channel where the treatment for a suspicious user was updated.
- moderator: :class:`~twitchio.PartialUser`
- The moderator who updated the terms.
- user: :class:`~twitchio.PartialUser`
- The the user that sent the message.
- trust_status: :class:`Literal["active_monitoring", "restricted", "none"]`
- The status set for the suspicious user. Can be the following: “none”, “active_monitoring”, or “restricted”.
- """
-
- __slots__ = ("broadcaster", "moderator", "user", "trust_status")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.moderator: PartialUser = _transform_user(client, data, "moderator_user")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.trust_status: Literal["active_monitoring", "restricted", "none"] = data["low_trust_status"]
-
-
-class ChannelAdBreakBeginData(EventData):
- """
- An ad begin event.
-
- Attributes
- -----------
- is_automatic: :class:`bool`
- Whether the ad was run manually or automatically via ads manager.
- broadcaster: :class:`~twitchio.PartialUser`
- The channel where a midroll commercial break has started running.
- requester: Optional[:class:`twitchio.PartialUser`]
- The user who started the ad break. Will be ``None`` if ``is_automatic`` is ``True``.
- duration: :class:`int`
- The ad duration in seconds.
- started_at: :class:`datetime.datetime`
- When the ad began.
- """
-
- __slots__ = ("is_automatic", "broadcaster", "requester", "duration", "started_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.is_automatic: bool = data["is_automatic"]
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster_user")
- self.requester: Optional[PartialUser] = (
- None if self.is_automatic else _transform_user(client, data, "requester_user")
- )
- self.duration: int = data["duration_seconds"]
- self.started_at: datetime.datetime = _parse_datetime(data["started_at"])
-
-
-class AutoCustomReward:
- """
- A reward object for an Auto Reward Redeem.
-
- Attributes
- -----------
- type: :class:`str`
- The type of the reward. One of ``single_message_bypass_sub_mode``, ``send_highlighted_message``, ``random_sub_emote_unlock``,
- ``chosen_sub_emote_unlock``, ``chosen_modified_sub_emote_unlock``, ``message_effect``, ``gigantify_an_emote``, ``celebration``.
- cost: :class:`int`
- How much the reward costs.
- unlocked_emote_id: Optional[:class:`str`]
- The unlocked emote, if applicable.
- unlocked_emote_name: Optional[:class:`str`]
- The unlocked emote, if applicable.
- """
-
- def __init__(self, data: dict):
- self.type: str = data["type"]
- self.cost: int = data["cost"]
- self.unlocked_emote_id: Optional[str] = data["unlocked_emote"] and data["unlocked_emote"]["id"]
- self.unlocked_emote_name: Optional[str] = data["unlocked_emote"] and data["unlocked_emote"]["name"]
-
-
-class AutoRewardRedeem(EventData):
- """
- Represents an automatic reward redemption.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The channel where the reward was redeemed.
- user: :class:`~twitchio.PartialUser`
- The user that redeemed the reward.
- id: :class:`str`
- The ID of the redemption.
- reward: :class:`AutoCustomReward`
- The reward that was redeemed.
- message: :class:`str`
- The message the user sent.
- message_emotes: :class:`dict`
- The emote data for the message.
- user_input: Optional[:class:`str`]
- The input to the reward, if it requires any.
- redeemed_at: :class:`datetime.datetime`
- When the reward was redeemed.
- """
-
- __slots__ = ("broadcaster", "user", "id", "reward", "message", "message_emotes", "user_input", "redeemed_at")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster")
- self.user: PartialUser = _transform_user(client, data, "user")
- self.id: str = data["id"]
- self.reward = AutoCustomReward(data["reward"])
- self.message: str = data["message"]
- self.message_emotes: dict = data["message_emotes"]
- self.user_input: Optional[str] = data["user_input"]
- self.redeemed_at: datetime.datetime = _parse_datetime(data["redeemed_at"])
-
-
-class ChannelVIPAddRemove(EventData):
- """
- Represents a VIP being added/removed from a channel.
-
- Attributes
- -----------
- broadcaster: :class:`~twitchio.PartialUser`
- The channel that the VIP was added/removed from.
- user: :class:`~twitchio.PartialUser`
- The user that was added/removed as a VIP.
- """
-
- __slots__ = ("broadcaster", "user")
-
- def __init__(self, client: EventSubClient, data: dict) -> None:
- self.broadcaster: PartialUser = _transform_user(client, data, "broadcaster")
- self.user: PartialUser = _transform_user(client, data, "user")
-
-
-_DataType = Union[
- ChannelBanData,
- ChannelUnbanData,
- ChannelSubscribeData,
- ChannelSubscriptionEndData,
- ChannelSubscriptionGiftData,
- ChannelSubscriptionMessageData,
- ChannelCheerData,
- ChannelUpdateData,
- ChannelFollowData,
- ChannelRaidData,
- ChannelModeratorAddRemoveData,
- ChannelGoalBeginProgressData,
- ChannelGoalEndData,
- CustomRewardAddUpdateRemoveData,
- CustomRewardRedemptionAddUpdateData,
- HypeTrainBeginProgressData,
- HypeTrainEndData,
- PollBeginProgressData,
- PollEndData,
- PredictionBeginProgressData,
- PredictionLockData,
- PredictionEndData,
- StreamOnlineData,
- StreamOfflineData,
- UserAuthorizationGrantedData,
- UserAuthorizationRevokedData,
- UserUpdateData,
- ChannelShieldModeBeginData,
- ChannelShieldModeEndData,
- ChannelShoutoutCreateData,
- ChannelShoutoutReceiveData,
- ChannelCharityDonationData,
- ChannelUnbanRequestCreateData,
- ChannelUnbanRequestResolveData,
- AutomodMessageHoldData,
- AutomodMessageUpdateData,
- AutomodSettingsUpdateData,
- AutomodTermsUpdateData,
- SuspiciousUserUpdateData,
- ChannelModerateData,
- AutoRewardRedeem,
- ChannelVIPAddRemove,
- ChannelAdBreakBeginData,
-]
-
-
-class _SubTypesMeta(type):
- def __new__(mcs, clsname, bases, attributes):
- attributes["_type_map"] = {args[0]: args[2] for name, args in attributes.items() if not name.startswith("_")}
- attributes["_name_map"] = {args[0]: name for name, args in attributes.items() if not name.startswith("_")}
- return super().__new__(mcs, clsname, bases, attributes)
-
-
-class _SubscriptionTypes(metaclass=_SubTypesMeta):
- _type_map: Dict[str, Type[_DataType]]
- _name_map: Dict[str, str]
-
- automod_message_hold = "automod.message.hold", 1, AutomodMessageHoldData
- automod_message_update = "automod.message.update", 1, AutomodMessageUpdateData
- automod_settings_update = "automod.settings.update", 1, AutomodSettingsUpdateData
- automod_terms_update = "automod.terms.update", 1, AutomodTermsUpdateData
-
- follow = "channel.follow", 1, ChannelFollowData
- followV2 = "channel.follow", 2, ChannelFollowData
- subscription = "channel.subscribe", 1, ChannelSubscribeData
- subscription_end = "channel.subscription.end", 1, ChannelSubscriptionEndData
- subscription_gift = "channel.subscription.gift", 1, ChannelSubscriptionGiftData
- subscription_message = "channel.subscription.message", 1, ChannelSubscriptionMessageData
- cheer = "channel.cheer", 1, ChannelCheerData
- raid = "channel.raid", 1, ChannelRaidData
- ban = "channel.ban", 1, ChannelBanData
- unban = "channel.unban", 1, ChannelUnbanData
-
- channel_update = "channel.update", 1, ChannelUpdateData
- channel_moderator_add = "channel.moderator.add", 1, ChannelModeratorAddRemoveData
- channel_moderator_remove = "channel.moderator.remove", 1, ChannelModeratorAddRemoveData
- channel_reward_add = "channel.channel_points_custom_reward.add", 1, CustomRewardAddUpdateRemoveData
- channel_reward_update = "channel.channel_points_custom_reward.update", 1, CustomRewardAddUpdateRemoveData
- channel_reward_remove = "channel.channel_points_custom_reward.remove", 1, CustomRewardAddUpdateRemoveData
- channel_reward_redeem = (
- "channel.channel_points_custom_reward_redemption.add",
- 1,
- CustomRewardRedemptionAddUpdateData,
- )
- channel_reward_redeem_updated = (
- "channel.channel_points_custom_reward_redemption.update",
- 1,
- CustomRewardRedemptionAddUpdateData,
- )
-
- auto_reward_redeem = "channel.channel_points_automatic_reward_redemption.add", 1, AutoRewardRedeem
-
- channel_goal_begin = "channel.goal.begin", 1, ChannelGoalBeginProgressData
- channel_goal_progress = "channel.goal.progress", 1, ChannelGoalBeginProgressData
- channel_goal_end = "channel.goal.end", 1, ChannelGoalEndData
-
- channel_shield_mode_begin = "channel.shield_mode.begin", 1, ChannelShieldModeBeginData
- channel_shield_mode_end = "channel.shield_mode.end", 1, ChannelShieldModeEndData
-
- channel_shoutout_create = "channel.shoutout.create", 1, ChannelShoutoutCreateData
- channel_shoutout_receive = "channel.shoutout.receive", 1, ChannelShoutoutReceiveData
-
- channel_charity_donate = "channel.charity_campaign.donate", 1, ChannelCharityDonationData
-
- channel_moderate = "channel.moderate", 1, ChannelModerateData
-
- hypetrain_begin = "channel.hype_train.begin", 1, HypeTrainBeginProgressData
- hypetrain_progress = "channel.hype_train.progress", 1, HypeTrainBeginProgressData
- hypetrain_end = "channel.hype_train.end", 1, HypeTrainEndData
-
- poll_begin = "channel.poll.begin", 1, PollBeginProgressData
- poll_progress = "channel.poll.progress", 1, PollBeginProgressData
- poll_end = "channel.poll.end", 1, PollEndData
-
- prediction_begin = "channel.prediction.begin", 1, PredictionBeginProgressData
- prediction_progress = "channel.prediction.progress", 1, PredictionBeginProgressData
- prediction_lock = "channel.prediction.lock", 1, PredictionLockData
- prediction_end = "channel.prediction.end", 1, PredictionEndData
-
- stream_start = "stream.online", 1, StreamOnlineData
- stream_end = "stream.offline", 1, StreamOfflineData
-
- unban_request_create = "channel.unban_request.create", 1, ChannelUnbanRequestCreateData
- unban_request_resolve = "channel.unban_request.resolve", 1, ChannelUnbanRequestResolveData
-
- channel_vip_add = "channel.vip.add", 1, ChannelVIPAddRemove
- channel_vip_remove = "channel.vip.remove", 1, ChannelVIPAddRemove
-
- user_authorization_grant = "user.authorization.grant", 1, UserAuthorizationGrantedData
- user_authorization_revoke = "user.authorization.revoke", 1, UserAuthorizationRevokedData
-
- user_update = "user.update", 1, UserUpdateData
-
- suspicious_user_update = "channel.suspicious_user.update", 1, SuspiciousUserUpdateData
-
- channel_ad_break_begin = "channel.ad_break.begin", 1, ChannelAdBreakBeginData
-
-
-SubscriptionTypes = _SubscriptionTypes()
-
-
-class TransportType(Enum):
- webhook = "webhook"
- websocket = "websocket"
diff --git a/twitchio/ext/eventsub/server.py b/twitchio/ext/eventsub/server.py
deleted file mode 100644
index fb563ede..00000000
--- a/twitchio/ext/eventsub/server.py
+++ /dev/null
@@ -1,513 +0,0 @@
-import asyncio
-import logging
-import socket
-import warnings
-from typing import Union, Tuple, Type, Optional, Any
-from collections.abc import Iterable
-
-import yarl
-from aiohttp import web
-
-from twitchio import Client, PartialUser
-from . import models, http
-
-try:
- from ssl import SSLContext
-except:
- SSLContext = Any
-
-__all__ = ("EventSubClient",)
-
-logger = logging.getLogger("twitchio.ext.eventsub")
-
-_message_types = {
- "webhook_callback_verification": models.ChallengeEvent,
- "notification": models.NotificationEvent,
- "revocation": models.RevokationEvent,
-}
-
-
-class EventSubClient(web.Application):
- def __init__(self, client: Client, webhook_secret: str, callback_route: str, token: str = None):
- self.client = client
- self.secret = webhook_secret
- self.route = callback_route
- self._http = http.EventSubHTTP(self, token=token)
- super(EventSubClient, self).__init__()
- self.router.add_post(yarl.URL(self.route).path, self._callback)
- self._closing = asyncio.Event()
-
- async def listen(self, **kwargs):
- self._closing.clear()
- await self.client.loop.create_task(self._run_app(**kwargs))
-
- def stop(self):
- self._closing.set()
-
- async def delete_subscription(self, subscription_id: str):
- await self._http.delete_subscription(subscription_id)
-
- async def delete_all_active_subscriptions(self):
- # A convenience method
- active_subscriptions = await self.get_subscriptions("enabled")
- for subscription in active_subscriptions:
- await self.delete_subscription(subscription.id)
-
- async def get_subscriptions(
- self, status: Optional[str] = None, sub_type: Optional[str] = None, user_id: Optional[int] = None
- ):
- # All possible statuses are:
- #
- # enabled: designates that the subscription is in an operable state and is valid.
- # webhook_callback_verification_pending: webhook is pending verification of the callback specified in the subscription creation request.
- # webhook_callback_verification_failed: webhook failed verification of the callback specified in the subscription creation request.
- # notification_failures_exceeded: notification delivery failure rate was too high.
- # authorization_revoked: authorization for user(s) in the condition was revoked.
- # user_removed: a user in the condition of the subscription was removed.
- return await self._http.get_subscriptions(status, sub_type, user_id)
-
- async def subscribe_user_updated(self, user: Union[PartialUser, str, int]):
- if isinstance(user, PartialUser):
- user = user.id
-
- user = str(user)
- return await self._http.create_webhook_subscription(models.SubscriptionTypes.user_update, {"user_id": user})
-
- async def subscribe_channel_raid(
- self, from_broadcaster: Union[PartialUser, str, int] = None, to_broadcaster: Union[PartialUser, str, int] = None
- ):
- if (not from_broadcaster and not to_broadcaster) or (from_broadcaster and to_broadcaster):
- raise ValueError("Expected 1 of from_broadcaster or to_broadcaster")
-
- if from_broadcaster:
- who = "from_broadcaster_user_id"
- broadcaster = from_broadcaster
- else:
- who = "to_broadcaster_user_id"
- broadcaster = to_broadcaster
-
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- return await self._http.create_webhook_subscription(models.SubscriptionTypes.raid, {who: broadcaster})
-
- async def _subscribe_channel_points_reward(
- self, event, broadcaster: Union[PartialUser, str, int], reward_id: str = None
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- data = {"broadcaster_user_id": broadcaster}
- if reward_id:
- data["reward_id"] = reward_id
-
- return await self._http.create_webhook_subscription(event, data)
-
- async def _subscribe_with_broadcaster(
- self, event: Tuple[str, int, Type[models._DataType]], broadcaster: Union[PartialUser, str, int]
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- return await self._http.create_webhook_subscription(event, {"broadcaster_user_id": broadcaster})
-
- async def _subscribe_with_broadcaster_moderator(
- self,
- event: Tuple[str, int, Type[models._DataType]],
- broadcaster: Union[PartialUser, str, int],
- moderator: Union[PartialUser, str, int],
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
- if isinstance(moderator, PartialUser):
- moderator = moderator.id
-
- broadcaster = str(broadcaster)
- moderator = str(moderator)
- return await self._http.create_webhook_subscription(
- event, {"broadcaster_user_id": broadcaster, "moderator_user_id": moderator}
- )
-
- def subscribe_channel_bans(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.ban, broadcaster)
-
- def subscribe_channel_unbans(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.unban, broadcaster)
-
- def subscribe_channel_subscriptions(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription, broadcaster)
-
- def subscribe_channel_subscription_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_end, broadcaster)
-
- def subscribe_channel_subscription_gifts(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_gift, broadcaster)
-
- def subscribe_channel_subscription_messages(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_message, broadcaster)
-
- def subscribe_channel_cheers(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.cheer, broadcaster)
-
- def subscribe_channel_update(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_update, broadcaster)
-
- def subscribe_channel_follows(self, broadcaster: Union[PartialUser, str, int]):
- """
- .. warning::
- This endpoint is deprecated, use :func:`~EventSubClient.subscribe_channel_follows_v2`
-
- """
- warnings.warn(
- "subscribe_channel_follows is deprecated, use subscribe_channel_follows_v2 instead.", DeprecationWarning, 2
- )
-
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.follow, broadcaster)
-
- def subscribe_channel_follows_v2(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(models.SubscriptionTypes.followV2, broadcaster, moderator)
-
- def subscribe_channel_moderators_add(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_moderator_add, broadcaster)
-
- def subscribe_channel_moderators_remove(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_moderator_remove, broadcaster)
-
- def subscribe_channel_goal_begin(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_begin, broadcaster)
-
- def subscribe_channel_goal_progress(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_progress, broadcaster)
-
- def subscribe_channel_goal_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_end, broadcaster)
-
- def subscribe_channel_hypetrain_begin(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_begin, broadcaster)
-
- def subscribe_channel_hypetrain_progress(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_progress, broadcaster)
-
- def subscribe_channel_hypetrain_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_end, broadcaster)
-
- def subscribe_channel_stream_start(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.stream_start, broadcaster)
-
- def subscribe_channel_stream_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.stream_end, broadcaster)
-
- def subscribe_channel_points_reward_added(self, broadcaster: Union[PartialUser, str, int], reward_id: str):
- return self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_add, broadcaster, reward_id
- )
-
- def subscribe_channel_points_reward_updated(self, broadcaster: Union[PartialUser, str, int], reward_id: str):
- return self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_update, broadcaster, reward_id
- )
-
- def subscribe_channel_points_reward_removed(self, broadcaster: Union[PartialUser, str, int], reward_id: str):
- return self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_remove, broadcaster, reward_id
- )
-
- def subscribe_channel_points_redeemed(self, broadcaster: Union[PartialUser, str, int], reward_id: str = None):
- return self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_redeem, broadcaster, reward_id
- )
-
- def subscribe_channel_points_redeem_updated(self, broadcaster: Union[PartialUser, str, int], reward_id: str = None):
- return self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_redeem_updated, broadcaster, reward_id
- )
-
- def subscribe_channel_poll_begin(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_begin, broadcaster)
-
- def subscribe_channel_poll_progress(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_progress, broadcaster)
-
- def subscribe_channel_poll_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_end, broadcaster)
-
- def subscribe_channel_prediction_begin(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_begin, broadcaster)
-
- def subscribe_channel_prediction_progress(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_progress, broadcaster)
-
- def subscribe_channel_prediction_lock(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_lock, broadcaster)
-
- def subscribe_channel_prediction_end(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_end, broadcaster)
-
- def subscribe_channel_auto_reward_redeem(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.auto_reward_redeem, broadcaster)
-
- def subscribe_channel_shield_mode_begin(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shield_mode_begin, broadcaster, moderator
- )
-
- def subscribe_channel_shield_mode_end(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shield_mode_end, broadcaster, moderator
- )
-
- def subscribe_channel_shoutout_create(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shoutout_create, broadcaster, moderator
- )
-
- def subscribe_channel_shoutout_receive(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shoutout_receive, broadcaster, moderator
- )
-
- def subscribe_channel_unban_request_create(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.unban_request_create, broadcaster, moderator
- )
-
- def subscribe_channel_unban_request_resolve(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.unban_request_resolve, broadcaster, moderator
- )
-
- def subscribe_automod_message_hold(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_message_hold, broadcaster, moderator
- )
-
- def subscribe_automod_message_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_message_update, broadcaster, moderator
- )
-
- def subscribe_automod_settings_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_settings_update, broadcaster, moderator
- )
-
- def subscribe_automod_terms_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_terms_update, broadcaster, moderator
- )
-
- def subscribe_channel_charity_donate(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_charity_donate, broadcaster)
-
- def subscribe_suspicious_user_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.suspicious_user_update, broadcaster, moderator
- )
-
- def subscribe_channel_moderate(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int]
- ):
- return self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_moderate, broadcaster, moderator
- )
-
- def subscribe_channel_vip_add(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_vip_add, broadcaster)
-
- def subscribe_channel_vip_remove(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_vip_remove, broadcaster)
-
- def subscribe_channel_ad_break_begin(self, broadcaster: Union[PartialUser, str, int]):
- return self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_ad_break_begin, broadcaster)
-
- async def subscribe_user_authorization_granted(self):
- return await self._http.create_webhook_subscription(
- models.SubscriptionTypes.user_authorization_grant, {"client_id": self.client._http.client_id}
- )
-
- async def subscribe_user_authorization_revoked(self):
- return await self._http.create_webhook_subscription(
- models.SubscriptionTypes.user_authorization_revoke, {"client_id": self.client._http.client_id}
- )
-
- async def _callback(self, request: web.Request) -> web.Response:
- payload = await request.text()
- typ = request.headers.get("Twitch-Eventsub-Message-Type", "")
- if not typ:
- return web.Response(status=404)
-
- if typ not in _message_types:
- logger.warning(f"Unexpected message type: {typ}")
- return web.Response(status=400)
-
- logger.debug(f"Recived a message type: {typ}")
- event = _message_types[typ](self, payload, request)
- response = event.verify()
-
- if response.status != 200:
- return response
-
- if typ == "notification":
- self.client.run_event(
- f"eventsub_notification_{models.SubscriptionTypes._name_map[event.subscription.type]}", event
- )
- elif typ == "revocation":
- self.client.run_event("eventsub_revokation", event)
-
- return response
-
- async def _run_app(
- self,
- *,
- host: Optional[Union[str, web.HostSequence]] = None,
- port: Optional[int] = None,
- path: Optional[str] = None,
- sock: Optional[socket.socket] = None,
- shutdown_timeout: float = 60.0,
- ssl_context: Optional[SSLContext] = None,
- backlog: int = 128,
- access_log_class: Type[web.AbstractAccessLogger] = web.AccessLogger,
- access_log_format: str = web.AccessLogger.LOG_FORMAT,
- access_log: Optional[logging.Logger] = web.access_logger,
- handle_signals: bool = True,
- reuse_address: Optional[bool] = None,
- reuse_port: Optional[bool] = None,
- ) -> None:
- # This function is pulled from aiohttp.web._run_app
- app = self
-
- runner = web.AppRunner(
- app,
- handle_signals=handle_signals,
- access_log_class=access_log_class,
- access_log_format=access_log_format,
- access_log=access_log,
- )
-
- await runner.setup()
-
- sites = []
-
- try:
- if host is not None:
- if isinstance(host, (str, bytes, bytearray, memoryview)):
- sites.append(
- web.TCPSite(
- runner,
- host,
- port,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- reuse_address=reuse_address,
- reuse_port=reuse_port,
- )
- )
- else:
- for h in host:
- sites.append(
- web.TCPSite(
- runner,
- h,
- port,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- reuse_address=reuse_address,
- reuse_port=reuse_port,
- )
- )
- elif path is None and sock is None or port is not None:
- sites.append(
- web.TCPSite(
- runner,
- port=port,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- reuse_address=reuse_address,
- reuse_port=reuse_port,
- )
- )
-
- if path is not None:
- if isinstance(path, (str, bytes, bytearray, memoryview)):
- sites.append(
- web.UnixSite(
- runner,
- path,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- )
- )
- else:
- for p in path:
- sites.append(
- web.UnixSite(
- runner,
- p,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- )
- )
-
- if sock is not None:
- if not isinstance(sock, Iterable):
- sites.append(
- web.SockSite(
- runner,
- sock,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- )
- )
- else:
- for s in sock:
- sites.append(
- web.SockSite(
- runner,
- s,
- shutdown_timeout=shutdown_timeout,
- ssl_context=ssl_context,
- backlog=backlog,
- )
- )
- for site in sites:
- await site.start()
-
- names = sorted(str(s.name) for s in runner.sites)
- logger.debug("Running EventSub server on {}".format(", ".join(names)))
-
- await self._closing.wait()
- finally:
- await runner.cleanup()
diff --git a/twitchio/ext/eventsub/websocket.py b/twitchio/ext/eventsub/websocket.py
deleted file mode 100644
index c153bf6a..00000000
--- a/twitchio/ext/eventsub/websocket.py
+++ /dev/null
@@ -1,583 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import logging
-
-import aiohttp
-from typing import Optional, TYPE_CHECKING, Tuple, Type, Dict, Callable, Generic, TypeVar, Awaitable, Union, cast, List
-from . import models, http
-from .models import _loads
-from twitchio import PartialUser, Unauthorized, HTTPException
-
-if TYPE_CHECKING:
- from typing_extensions import Literal
- from twitchio import Client
-
-logger = logging.getLogger("twitchio.ext.eventsub.ws")
-
-_message_types = {
- "notification": models.NotificationEvent,
- "revocation": models.RevokationEvent,
- "session_reconnect": models.ReconnectEvent,
- "session_keepalive": models.KeepAliveEvent,
-}
-_messages = Union[models.NotificationEvent, models.RevokationEvent, models.ReconnectEvent, models.KeepAliveEvent]
-
-
-class _Subscription:
- __slots__ = "event", "condition", "token", "subscription_id", "cost", "created"
-
- def __init__(self, event_type: Tuple[str, int, Type[models.EventData]], condition: Dict[str, str], token: str):
- self.event = event_type
- self.condition = condition
- self.token = token
- self.subscription_id: Optional[str] = None
- self.cost: Optional[int] = None
- self.created: asyncio.Future[Tuple[Literal[False], int] | Tuple[Literal[True], None]] | None = asyncio.Future()
-
-
-_T = TypeVar("_T")
-
-
-class _WakeupList(list, Generic[_T]):
- def __init__(self, *args):
- super().__init__(*args)
- self._append_waiters = []
- self._pop_waiters = []
-
- def _wakeup_append(self, obj: _T) -> None:
- try:
- loop = asyncio.get_running_loop()
- for cb in self._append_waiters:
- loop.create_task(cb(obj))
- except: # don't wake the waiters if theres no loop
- pass
-
- def _wakeup_pop(self, obj: _T) -> None:
- try:
- loop = asyncio.get_running_loop()
- for cb in self._pop_waiters:
- loop.create_task(cb(obj))
- except: # don't wake the waiters
- pass
-
- def append(self, obj: _T) -> None:
- self._wakeup_append(obj)
- super().append(obj)
-
- def insert(self, index: int, obj: _T) -> None:
- self._wakeup_append(obj)
- super().insert(index, obj)
-
- def __delitem__(self, key: int):
- self._wakeup_pop(self[key])
- super().__delitem__(key)
-
- def pop(self, index: int = ...) -> _T:
- resp = super().pop(index)
- self._wakeup_pop(resp)
- return resp
-
- def add_append_callback(self, cb: Callable[[_T], Awaitable[None]]) -> None:
- self._append_waiters.append(cb)
-
- def add_pop_callback(self, cb: Callable[[_T], Awaitable[None]]) -> None:
- self._pop_waiters.append(cb)
-
-
-class Websocket:
- URL = "wss://eventsub.wss.twitch.tv/ws"
-
- def __init__(self, client: Client, http: http.EventSubHTTP):
- self.client = client
- self._http = http
- self._subscription_pool = _WakeupList[_Subscription]()
- self._subscription_pool.add_append_callback(self._wakeup_and_connect)
- self._sock: Optional[aiohttp.ClientWebSocketResponse] = None
- self._pump_task: Optional[asyncio.Task] = None
- self._timeout: Optional[int] = None
- self._session_id: Optional[str] = None
- self._target_user_id: int | None = None # each websocket can only have one authenticated user on it for some bizzare reason, but this isnt documented anywhere
- self.remaining_slots: int = 300 # default to 300
-
- def __hash__(self) -> int:
- return hash(self.session_id)
-
- def __eq__(self, __value: object) -> bool:
- return __value is self
-
- @property
- def session_id(self) -> Optional[str]:
- return self._session_id
-
- @property
- def is_connected(self) -> bool:
- return self._sock is not None and not self._sock.closed
-
- async def _subscribe(self, obj: _Subscription) -> dict | None:
- try:
- resp = await self._http.create_websocket_subscription(obj.event, obj.condition, self._session_id, obj.token)
- except HTTPException as e:
- if obj.created:
- obj.created.set_result((False, e.status))
-
- else:
- logger.error(
- "An error (%s %s) occurred while attempting to resubscribe to an event on reconnect: %s",
- e.status,
- e.reason,
- e.message,
- )
-
- return None
-
- if obj.created:
- obj.created.set_result((True, None))
-
- data = resp["data"][0]
- self.remaining_slots = resp["max_total_cost"] - resp["total_cost"]
- obj.cost = data["cost"]
-
- return data
-
- def add_subscription(self, sub: _Subscription) -> None:
- self._subscription_pool.append(sub)
-
- async def _wakeup_and_connect(self, obj: _Subscription):
- if self.is_connected:
- await self._subscribe(obj)
- return
-
- async def connect(self, reconnect_url: Optional[str] = None):
- async with aiohttp.ClientSession() as session:
- sock = self._sock = await session.ws_connect(reconnect_url or self.URL)
- session.detach()
-
- welcome = await sock.receive_json(loads=_loads, timeout=3)
- logger.debug("Received websocket payload: %s", welcome)
- self._session_id = welcome["payload"]["session"]["id"]
- self._timeout = welcome["payload"]["session"]["keepalive_timeout_seconds"]
-
- logger.debug("Created websocket connection with session ID: %s and timeout %s", self._session_id, self._timeout)
-
- self._pump_task = self.client.loop.create_task(self.pump())
-
- if reconnect_url: # don't resubscribe to events
- return
-
- for sub in self._subscription_pool:
- await self._subscribe(sub)
-
- async def pump(self) -> None:
- sock: aiohttp.ClientWebSocketResponse = cast(aiohttp.ClientWebSocketResponse, self._sock)
- while self.is_connected:
- try:
- msg = await sock.receive_str(
- timeout=self._timeout + 1
- ) # extra jitter on the timeout in case of network lag
- if not msg:
- logger.warning("Received empty payload ")
-
- logger.debug("Received websocket payload: %s", msg)
- frame: _messages = self.parse_frame(_loads(msg))
- self.client.run_event("eventsub_debug", frame)
-
- if isinstance(frame, models.NotificationEvent):
- self.client.run_event(
- f"eventsub_notification_{models.SubscriptionTypes._name_map[frame.subscription.type]}", frame
- )
- self.client.run_event("eventsub_notification", frame)
-
- elif isinstance(frame, models.RevokationEvent):
- self.client.run_event("eventsub_revokation", frame)
-
- elif isinstance(frame, models.KeepAliveEvent):
- self.client.run_event("eventsub_keepalive", frame)
-
- elif isinstance(frame, models.ReconnectEvent):
- self.client.run_event("eventsub_reconnect", frame)
- self._sock = None
- await self.connect(frame.reconnect_url)
- await sock.close(code=aiohttp.WSCloseCode.GOING_AWAY, message=b"reconnecting")
- return
-
- except asyncio.TimeoutError:
- logger.warning(f"Websocket timed out (timeout: {self._timeout}), reconnecting")
- await cast(aiohttp.ClientWebSocketResponse, self._sock).close(
- code=aiohttp.WSCloseCode.ABNORMAL_CLOSURE, message=b"timeout surpassed"
- )
- await self.connect()
- return
-
- except TypeError as e:
- logger.warning(f"Received bad frame: {e.args[0]}")
-
- if "257" in e.args[0]: # websocket was closed, reconnect
- logger.info("Known bad frame, restarting connection")
- await self.connect()
- return
-
- except Exception as e:
- logger.error("Exception in the pump function!", exc_info=e)
- raise
-
- def parse_frame(self, frame: dict) -> _messages:
- type_: str = frame["metadata"]["message_type"]
- return _message_types[type_](self, frame, None)
-
-
-class EventSubWSClient:
- def __init__(self, client: Client):
- self.client = client
- self._http: http.EventSubHTTP = http.EventSubHTTP(self, token=None)
-
- self._sockets: List[Websocket] = []
- self._ready_to_subscribe: List[_Subscription] = []
-
- async def _assign_subscription(self, sub: _Subscription) -> None:
- if not self._sockets:
- w = Websocket(self.client, self._http)
- await w.connect()
-
- self._sockets.append(w)
-
- success = False
- bad_sockets: set[Websocket] | None = None # dont allocate unless we need it
-
- while not success:
- s: Websocket | None = None # really it'll never be none after this point, but ok pyright
-
- if bad_sockets is not None:
- socks = filter(lambda sock: sock not in bad_sockets, self._sockets) # type: ignore
- else:
- socks = self._sockets
-
- for s in socks:
- if s.remaining_slots > 0:
- s.add_subscription(sub)
- break
-
- else: # there are no sockets, create one and break
- s = Websocket(self.client, self._http)
- await s.connect()
-
- s.add_subscription(sub)
- return
-
- assert sub.created is not None # go away pyright
-
- success, status = await sub.created
-
- if not success and status == 400:
- # can't be on that socket due to someone else being on it, try again on a different one
- if bad_sockets is None:
- bad_sockets = set()
-
- bad_sockets.add(s)
- sub.created = asyncio.Future()
- continue
-
- elif not success and status in (401, 403):
- raise Unauthorized("You are not authorized to make this subscription", status=status)
-
- elif not success:
- raise RuntimeError(f"Subscription failed, reason unknown. Status: {status}")
-
- else:
- sub.created = None # don't need that future to sit in memory
- break
-
- async def subscribe_user_updated(self, user: Union[PartialUser, str, int], token: str):
- if isinstance(user, PartialUser):
- user = user.id
-
- user = str(user)
- sub = _Subscription(models.SubscriptionTypes.user_update, {"user_id": user}, token)
- await self._assign_subscription(sub)
-
- async def subscribe_channel_raid(
- self,
- token: str,
- from_broadcaster: Union[PartialUser, str, int] = None,
- to_broadcaster: Union[PartialUser, str, int] = None,
- ):
- if (not from_broadcaster and not to_broadcaster) or (from_broadcaster and to_broadcaster):
- raise ValueError("Expected 1 of from_broadcaster or to_broadcaster")
-
- if from_broadcaster:
- who = "from_broadcaster_user_id"
- broadcaster = from_broadcaster
- else:
- who = "to_broadcaster_user_id"
- broadcaster = to_broadcaster
-
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- sub = _Subscription(models.SubscriptionTypes.raid, {who: broadcaster}, token)
- await self._assign_subscription(sub)
-
- async def _subscribe_channel_points_reward(
- self,
- event: Tuple[str, int, Type[models._DataType]],
- broadcaster: Union[PartialUser, str, int],
- token: str,
- reward_id: str = None,
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- data = {"broadcaster_user_id": broadcaster}
- if reward_id:
- data["reward_id"] = reward_id
-
- sub = _Subscription(event, data, token)
- await self._assign_subscription(sub)
-
- async def _subscribe_with_broadcaster(
- self, event: Tuple[str, int, Type[models._DataType]], broadcaster: Union[PartialUser, str, int], token: str
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
-
- broadcaster = str(broadcaster)
- sub = _Subscription(event, {"broadcaster_user_id": broadcaster}, token)
- await self._assign_subscription(sub)
-
- async def _subscribe_with_broadcaster_moderator(
- self,
- event: Tuple[str, int, Type[models._DataType]],
- broadcaster: Union[PartialUser, str, int],
- moderator: Union[PartialUser, str, int],
- token: str,
- ):
- if isinstance(broadcaster, PartialUser):
- broadcaster = broadcaster.id
- if isinstance(moderator, PartialUser):
- moderator = moderator.id
-
- broadcaster = str(broadcaster)
- moderator = str(moderator)
- sub = _Subscription(event, {"broadcaster_user_id": broadcaster, "moderator_user_id": moderator}, token)
- await self._assign_subscription(sub)
-
- async def subscribe_channel_bans(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.ban, broadcaster, token)
-
- async def subscribe_channel_unbans(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.unban, broadcaster, token)
-
- async def subscribe_channel_subscriptions(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription, broadcaster, token)
-
- async def subscribe_channel_subscription_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_end, broadcaster, token)
-
- async def subscribe_channel_subscription_gifts(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_gift, broadcaster, token)
-
- async def subscribe_channel_subscription_messages(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.subscription_message, broadcaster, token)
-
- async def subscribe_channel_cheers(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.cheer, broadcaster, token)
-
- async def subscribe_channel_update(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_update, broadcaster, token)
-
- async def subscribe_channel_follows(self, broadcaster: Union[PartialUser, str, int], token: str):
- raise RuntimeError("This subscription has been removed by twitch, please use subscribe_channel_follows_v2")
-
- async def subscribe_channel_follows_v2(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.followV2, broadcaster, moderator, token
- )
-
- async def subscribe_channel_moderators_add(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_moderator_add, broadcaster, token)
-
- async def subscribe_channel_moderators_remove(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_moderator_remove, broadcaster, token)
-
- async def subscribe_channel_goal_begin(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_begin, broadcaster, token)
-
- async def subscribe_channel_goal_progress(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_progress, broadcaster, token)
-
- async def subscribe_channel_goal_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_goal_end, broadcaster, token)
-
- async def subscribe_channel_hypetrain_begin(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_begin, broadcaster, token)
-
- async def subscribe_channel_hypetrain_progress(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_progress, broadcaster, token)
-
- async def subscribe_channel_hypetrain_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.hypetrain_end, broadcaster, token)
-
- async def subscribe_channel_stream_start(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.stream_start, broadcaster, token)
-
- async def subscribe_channel_stream_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.stream_end, broadcaster, token)
-
- async def subscribe_channel_points_reward_added(
- self, broadcaster: Union[PartialUser, str, int], reward_id: str, token: str
- ):
- await self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_add, broadcaster, token, reward_id
- )
-
- async def subscribe_channel_points_reward_updated(
- self, broadcaster: Union[PartialUser, str, int], reward_id: str, token: str
- ):
- await self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_update, broadcaster, token, reward_id
- )
-
- async def subscribe_channel_points_reward_removed(
- self, broadcaster: Union[PartialUser, str, int], reward_id: str, token: str
- ):
- await self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_remove, broadcaster, token, reward_id
- )
-
- async def subscribe_channel_points_redeemed(
- self, broadcaster: Union[PartialUser, str, int], token: str, reward_id: str = None
- ):
- await self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_redeem, broadcaster, token, reward_id
- )
-
- async def subscribe_channel_points_redeem_updated(
- self, broadcaster: Union[PartialUser, str, int], token: str, reward_id: str = None
- ):
- await self._subscribe_channel_points_reward(
- models.SubscriptionTypes.channel_reward_redeem_updated, broadcaster, token, reward_id
- )
-
- async def subscribe_channel_poll_begin(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_begin, broadcaster, token)
-
- async def subscribe_channel_poll_progress(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_progress, broadcaster, token)
-
- async def subscribe_channel_poll_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.poll_end, broadcaster, token)
-
- async def subscribe_channel_prediction_begin(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_begin, broadcaster, token)
-
- async def subscribe_channel_prediction_progress(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_progress, broadcaster, token)
-
- async def subscribe_channel_prediction_lock(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_lock, broadcaster, token)
-
- async def subscribe_channel_prediction_end(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.prediction_end, broadcaster, token)
-
- async def subscribe_channel_auto_reward_redeem(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.auto_reward_redeem, broadcaster, token)
-
- async def subscribe_channel_shield_mode_begin(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shield_mode_begin, broadcaster, moderator, token
- )
-
- async def subscribe_channel_shield_mode_end(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shield_mode_end, broadcaster, moderator, token
- )
-
- async def subscribe_channel_shoutout_create(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shoutout_create, broadcaster, moderator, token
- )
-
- async def subscribe_channel_shoutout_receive(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_shoutout_receive, broadcaster, moderator, token
- )
-
- async def subscribe_channel_charity_donate(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_charity_donate, broadcaster, token)
-
- async def subscribe_channel_unban_request_create(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.unban_request_create, broadcaster, moderator, token
- )
-
- async def subscribe_channel_unban_request_resolve(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.unban_request_resolve, broadcaster, moderator, token
- )
-
- async def subscribe_automod_message_hold(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_message_hold, broadcaster, moderator, token
- )
-
- async def subscribe_automod_message_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_message_update, broadcaster, moderator, token
- )
-
- async def subscribe_automod_settings_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_settings_update, broadcaster, moderator, token
- )
-
- async def subscribe_automod_terms_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.automod_terms_update, broadcaster, moderator, token
- )
-
- async def subscribe_suspicious_user_update(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.suspicious_user_update, broadcaster, moderator, token
- )
-
- async def subscribe_channel_moderate(
- self, broadcaster: Union[PartialUser, str, int], moderator: Union[PartialUser, str, int], token: str
- ):
- await self._subscribe_with_broadcaster_moderator(
- models.SubscriptionTypes.channel_moderate, broadcaster, moderator, token
- )
-
- async def subscribe_channel_vip_add(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_vip_add, broadcaster, token)
-
- async def subscribe_channel_vip_remove(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_vip_remove, broadcaster, token)
-
- async def subscribe_channel_ad_break_begin(self, broadcaster: Union[PartialUser, str, int], token: str):
- await self._subscribe_with_broadcaster(models.SubscriptionTypes.channel_ad_break_begin, broadcaster, token)
diff --git a/twitchio/ext/pubsub/__init__.py b/twitchio/ext/pubsub/__init__.py
deleted file mode 100644
index 3926e387..00000000
--- a/twitchio/ext/pubsub/__init__.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from .topics import *
-from .websocket import *
-from .pool import *
-from .models import *
diff --git a/twitchio/ext/pubsub/models.py b/twitchio/ext/pubsub/models.py
deleted file mode 100644
index 0b87c936..00000000
--- a/twitchio/ext/pubsub/models.py
+++ /dev/null
@@ -1,503 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from typing import List, Optional
-
-from twitchio import PartialUser, Client, Channel, CustomReward, parse_timestamp
-
-
-__all__ = (
- "PoolError",
- "PoolFull",
- "PubSubMessage",
- "PubSubBitsMessage",
- "PubSubBitsBadgeMessage",
- "PubSubChatMessage",
- "PubSubBadgeEntitlement",
- "PubSubChannelPointsMessage",
- "PubSubModerationAction",
- "PubSubModerationActionModeratorAdd",
- "PubSubModerationActionBanRequest",
- "PubSubModerationActionChannelTerms",
- "PubSubChannelSubscribe",
-)
-
-
-class PubSubError(Exception):
- pass
-
-
-class ConnectionFailure(PubSubError):
- pass
-
-
-class PoolError(PubSubError):
- pass
-
-
-class PoolFull(PoolError):
- pass
-
-
-class PubSubChatMessage:
- """
- A message received from twitch.
-
- Attributes
- -----------
- content: :class:`str`
- The content received
- id: :class:`str`
- The id of the payload
- type: :class:`str`
- The payload type
- """
-
- __slots__ = "content", "id", "type"
-
- def __init__(self, content: str, id: str, type: str):
- self.content = content
- self.id = id
- self.type = type
-
-
-class PubSubBadgeEntitlement:
- """
- A badge entitlement
-
- Attributes
- -----------
- new: :class:`int`
- The new badge
- old: :class:`int`
- The old badge
- """
-
- __slots__ = "new", "old"
-
- def __init__(self, new: int, old: int):
- self.new = new
- self.old = old
-
-
-class PubSubMessage:
- """
- A message from the pubsub websocket
-
- Attributes
- -----------
- topic: :class:`str`
- The topic subscribed to
- """
-
- __slots__ = "topic", "_data"
-
- def __init__(self, client: Client, topic: Optional[str], data: dict):
- self.topic = topic
- self._data = data
-
-
-class PubSubBitsMessage(PubSubMessage):
- """
- A Bits message
-
- Attributes
- -----------
- message: :class:`PubSubChatMessage`
- The message sent along with the bits.
- badge_entitlement: Optional[:class:`PubSubBadgeEntitlement`]
- The badges received, if any.
- bits_used: :class:`int`
- The amount of bits used.
- channel_id: :class:`int`
- The channel the bits were given to.
- user: Optional[:class:`twitchio.PartialUser`]
- The user giving the bits. Can be None if anonymous.
- version: :class:`str`
- The event version.
- """
-
- __slots__ = "badge_entitlement", "bits_used", "channel_id", "context", "anonymous", "message", "user", "version"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
-
- data = data["message"]
- self.message = PubSubChatMessage(data["data"]["chat_message"], data["message_id"], data["message_type"])
- self.badge_entitlement = (
- PubSubBadgeEntitlement(
- data["data"]["badge_entitlement"]["new_version"], data["data"]["badge_entitlement"]["old_version"]
- )
- if data["data"]["badge_entitlement"]
- else None
- )
- self.bits_used: int = data["data"]["bits_used"]
- self.channel_id: int = int(data["data"]["channel_id"])
- self.user = (
- PartialUser(client._http, data["data"]["user_id"], data["data"]["user_name"])
- if data["data"]["user_id"]
- else None
- )
- self.version: str = data["version"]
-
-
-class PubSubBitsBadgeMessage(PubSubMessage):
- """
- A Badge message
-
- Attributes
- -----------
- user: :class:`twitchio.PartialUser`
- The user receiving the badge.
- channel: :class:`twitchio.Channel`
- The channel the user received the badge on.
- badge_tier: :class:`int`
- The tier of the badge
- message: :class:`str`
- The message sent in chat.
- timestamp: :class:`datetime.datetime`
- The time the event happened
- """
-
- __slots__ = "user", "channel", "badge_tier", "message", "timestamp"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
- data = data["message"]
- self.user = PartialUser(client._http, data["user_id"], data["user_name"])
- self.channel: Channel = client.get_channel(data["channel_name"]) or Channel(
- name=data["channel_name"], websocket=client._connection
- )
- self.badge_tier: int = data["badge_tier"]
- self.message: str = data["chat_message"]
- self.timestamp = parse_timestamp(data["time"])
-
-
-class PubSubChannelPointsMessage(PubSubMessage):
- """
- A Channel points redemption
-
- Attributes
- -----------
- timestamp: :class:`datetime.datetime`
- The timestamp the event happened.
- channel_id: :class:`int`
- The channel the reward was redeemed on.
- id: :class:`str`
- The id of the reward redemption.
- user: :class:`twitchio.PartialUser`
- The user redeeming the reward.
- reward: :class:`twitchio.CustomReward`
- The reward being redeemed.
- input: Optional[:class:`str`]
- The input the user gave, if any.
- status: :class:`str`
- The status of the reward.
- """
-
- __slots__ = "timestamp", "channel_id", "user", "id", "reward", "input", "status"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
-
- redemption = data["message"]["data"]["redemption"]
-
- self.timestamp = parse_timestamp(redemption["redeemed_at"])
- self.channel_id: int = int(redemption["channel_id"])
- self.id: str = redemption["id"]
- self.user = PartialUser(client._http, redemption["user"]["id"], redemption["user"]["display_name"])
- self.reward = CustomReward(client._http, redemption["reward"], PartialUser(client._http, self.channel_id, None))
- self.input: Optional[str] = redemption.get("user_input")
- self.status: str = redemption["status"]
-
-
-class PubSubModerationAction(PubSubMessage):
- """
- A basic moderation action.
-
- Attributes
- -----------
- action: :class:`str`
- The action taken.
- args: List[:class:`str`]
- The arguments given to the command.
- created_by: :class:`twitchio.PartialUser`
- The user that created the action.
- message_id: Optional[:class:`str`]
- The id of the message that created this action.
- target: :class:`twitchio.PartialUser`
- The target of this action.
- from_automod: :class:`bool`
- Whether this action was done automatically or not.
- """
-
- __slots__ = "action", "args", "created_by", "message_id", "target", "from_automod"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
- self.action: str = data["message"]["data"]["moderation_action"]
- self.args: List[str] = data["message"]["data"]["args"]
- self.created_by = PartialUser(
- client._http, data["message"]["data"]["created_by_user_id"], data["message"]["data"]["created_by"]
- )
- self.message_id: Optional[str] = data["message"]["data"].get("msg_id")
- self.target = (
- PartialUser(
- client._http, data["message"]["data"]["target_user_id"], data["message"]["data"]["target_user_login"]
- )
- if data["message"]["data"]["target_user_id"]
- else None
- )
- self.from_automod: bool = data["message"]["data"].get("from_automod", False)
-
-
-class PubSubModerationActionBanRequest(PubSubMessage):
- """
- A Ban/Unban event
-
- Attributes
- -----------
- action: :class:`str`
- The action taken.
- args: List[:class:`str`]
- The arguments given to the command.
- created_by: :class:`twitchio.PartialUser`
- The user that created the action.
- target: :class:`twitchio.PartialUser`
- The target of this action.
- """
-
- __slots__ = "action", "args", "created_by", "message_id", "target"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
- self.action: str = data["message"]["data"]["moderation_action"]
- self.args: List[str] = data["message"]["data"]["moderator_message"]
- self.created_by = PartialUser(
- client._http, data["message"]["data"]["created_by_id"], data["message"]["data"]["created_by_login"]
- )
- self.target = (
- PartialUser(
- client._http, data["message"]["data"]["target_user_id"], data["message"]["data"]["target_user_login"]
- )
- if data["message"]["data"]["target_user_id"]
- else None
- )
-
-
-class PubSubModerationActionChannelTerms(PubSubMessage):
- """
- A channel Terms update.
-
- Attributes
- -----------
- type: :class:`str`
- The type of action taken.
- channel_id: :class:`int`
- The channel id the action occurred on.
- id: :class:`str`
- The id of the Term.
- text: :class:`str`
- The text of the modified Term.
- requester: :class:`twitchio.PartialUser`
- The requester of this Term.
- """
-
- __slots__ = "type", "channel_id", "id", "text", "requester", "expires_at", "updated_at"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
- self.type: str = data["message"]["data"]["type"]
- self.channel_id = int(data["message"]["data"]["channel_id"])
- self.id: str = data["message"]["data"]["id"]
- self.text: str = data["message"]["data"]["text"]
- self.requester = PartialUser(
- client._http, data["message"]["data"]["requester_id"], data["message"]["data"]["requester_login"]
- )
-
- self.expires_at = (
- parse_timestamp(data["message"]["data"]["expires_at"]) if data["message"]["data"]["expires_at"] else None
- )
- self.updated_at = (
- parse_timestamp(data["message"]["data"]["updated_at"]) if data["message"]["data"]["updated_at"] else None
- )
-
-
-class PubSubChannelSubscribe(PubSubMessage):
- """
- Channel subscription
-
- Attributes
- -----------
- channel: :class:`twitchio.Channel`
- Channel that has been subscribed or subgifted.
- context: :class:`str`
- Event type associated with the subscription product.
- user: Optional[:class:`twitchio.PartialUser`]
- The person who subscribed or sent a gift subscription. Can be None if anonymous.
- message: :class:`str`
- Message sent with the sub/resub.
- emotes: Optional[List[:class:`dict`]]
- Emotes sent with the sub/resub.
- is_gift: :class:`bool`
- If this sub message was caused by a gift subscription.
- recipient: Optional[:class:`twitchio.PartialUser`]
- The person the who received the gift subscription.
- sub_plan: :class:`str`
- Subscription Plan ID.
- sub_plan_name: :class:`str`
- Channel Specific Subscription Plan Name.
- time: :class:`datetime.datetime`
- Time when the subscription or gift was completed. RFC 3339 format.
- cumulative_months: :class:`int`
- Cumulative number of tenure months of the subscription.
- streak_months: Optional[:class:`int`]
- Denotes the user's most recent (and contiguous) subscription tenure streak in the channel.
- multi_month_duration: Optional[:class:`int`]
- Number of months gifted as part of a single, multi-month gift OR number of months purchased as part of a multi-month subscription.
- """
-
- __slots__ = (
- "channel",
- "context",
- "user",
- "message",
- "emotes",
- "is_gift",
- "recipient",
- "sub_plan",
- "sub_plan_name",
- "time",
- "cumulative_months",
- "streak_months",
- "multi_month_duration",
- )
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
-
- subscription = data["message"]
-
- self.channel: Channel = client.get_channel(subscription["channel_name"]) or Channel(
- name=subscription["channel_name"], websocket=client._connection
- )
- self.context: str = subscription["context"]
- try:
- self.user = PartialUser(client._http, int(subscription["user_id"]), subscription["user_name"])
- except KeyError:
- self.user = None
-
- self.message: str = subscription["sub_message"]["message"]
- try:
- self.emotes = subscription["sub_message"]["emotes"]
- except KeyError:
- self.emotes = None
-
- self.is_gift: bool = subscription["is_gift"]
- try:
- self.recipient = PartialUser(
- client._http, int(subscription["recipient_id"]), subscription["recipient_user_name"]
- )
- except KeyError:
- self.recipient = None
-
- self.sub_plan: str = subscription["sub_plan"]
- self.sub_plan_name: str = subscription["sub_plan_name"]
- self.time = parse_timestamp(subscription["time"])
- try:
- self.cumulative_months = int(subscription["cumulative_months"])
- except KeyError:
- self.cumulative_months = None
- try:
- self.streak_months = int(subscription["streak_months"])
- except KeyError:
- self.streak_months = None
- try:
- self.multi_month_duration = int(subscription["multi_month_duration"])
- except KeyError:
- self.multi_month_duration = None
-
-
-class PubSubModerationActionModeratorAdd(PubSubMessage):
- """
- A moderator add event.
-
- Attributes
- -----------
- channel_id: :class:`int`
- The channel id the moderator was added to.
- moderation_action: :class:`str`
- Redundant.
- target: :class:`twitchio.PartialUser`
- The person who was added as a mod.
- created_by: :class:`twitchio.PartialUser`
- The person who added the mod.
- """
-
- __slots__ = "channel_id", "target", "moderation_action", "created_by"
-
- def __init__(self, client: Client, topic: str, data: dict):
- super().__init__(client, topic, data)
- self.channel_id = int(data["message"]["data"]["channel_id"])
- self.moderation_action: str = data["message"]["data"]["moderation_action"]
- self.target = PartialUser(
- client._http, data["message"]["data"]["target_user_id"], data["message"]["data"]["target_user_login"]
- )
- self.created_by = PartialUser(
- client._http, data["message"]["data"]["created_by_user_id"], data["message"]["data"]["created_by"]
- )
-
-
-_mod_actions = {
- "approve_unban_request": PubSubModerationActionBanRequest,
- "deny_unban_request": PubSubModerationActionBanRequest,
- "channel_terms_action": PubSubModerationActionChannelTerms,
- "moderator_added": PubSubModerationActionModeratorAdd,
- "moderation_action": PubSubModerationAction,
-}
-
-
-def _find_mod_action(client: Client, topic: str, data: dict):
- typ = data["message"]["type"]
- if typ in _mod_actions:
- return _mod_actions[typ](client, topic, data)
-
- else:
- raise ValueError(f"unknown pubsub moderation action '{typ}'")
-
-
-_mapping = {
- "channel-bits-events-v2": ("pubsub_bits", PubSubBitsMessage),
- "channel-bits-badge-unlocks": ("pubsub_bits_badge", PubSubBitsBadgeMessage),
- "channel-subscribe-events-v1": ("pubsub_subscription", PubSubChannelSubscribe),
- "chat_moderator_actions": ("pubsub_moderation", _find_mod_action),
- "channel-points-channel-v1": ("pubsub_channel_points", PubSubChannelPointsMessage),
- "whispers": ("pubsub_whisper", None),
-}
-
-
-def create_message(client, msg: dict):
- topic = msg["data"]["topic"].split(".")[0]
- r = _mapping[topic]
- return r[0], r[1](client, topic, msg["data"])
diff --git a/twitchio/ext/pubsub/pool.py b/twitchio/ext/pubsub/pool.py
deleted file mode 100644
index 1d39e760..00000000
--- a/twitchio/ext/pubsub/pool.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import copy
-import itertools
-import logging
-from typing import List, Optional
-
-from twitchio import Client
-from .websocket import PubSubWebsocket
-from .topics import Topic
-from . import models
-
-
-__all__ = ("PubSubPool",)
-
-logger = logging.getLogger("twitchio.ext.eventsub.pool")
-
-
-class PubSubPool:
- """
- The pool that manages connections to the pubsub server, and handles distributing topics across the connections.
-
- Attributes
- -----------
- client: :class:`twitchio.Client`
- The client that the pool will dispatch events to.
- """
-
- def __init__(self, client: Client, *, max_pool_size=10, max_connection_topics=50, mode="group"):
- self.client = client
- self._pool: List[PubSubWebsocket] = []
- self._topics = {}
- self._mode = mode
- self._max_size = max_pool_size
- self._max_connection_topics = max_connection_topics
-
- async def subscribe_topics(self, topics: List[Topic]):
- """|coro|
- Subscribes to a list of topics.
-
- Parameters
- -----------
- topics: List[:class:`Topic`]
- The topics to subscribe to
-
- """
- node = self._find_node(topics)
- if node is None:
- node = PubSubWebsocket(self.client, pool=self, max_topics=self._max_connection_topics)
- await node.connect()
- self._pool.append(node)
-
- await node.subscribe_topics(topics)
- self._topics.update({t: node for t in topics})
-
- async def unsubscribe_topics(self, topics: List[Topic]):
- """|coro|
- Unsubscribes from a list of topics.
-
- Parameters
- -----------
- topics: List[:class:`Topic`]
- The topics to unsubscribe from
-
- """
- for node, vals in itertools.groupby(topics, lambda t: self._topics[t]):
- await node.unsubscribe_topic(list(vals))
- if not node.topics:
- await node.disconnect()
- self._pool.remove(node)
-
- async def _process_auth_fail(self, nonce: str, node: PubSubWebsocket) -> None:
- topics = [topic for topic in self._topics if topic._nonce == nonce]
-
- for topic in topics:
- topic._nonce = None
- del self._topics[topic]
- node.topics.remove(topic)
-
- try:
- await self.auth_fail_hook(topics)
- except Exception as e:
- logger.error("Error occurred while calling auth_fail_hook.", exc_info=e)
-
- async def auth_fail_hook(self, topics: List[Topic]):
- """|coro|
- This is a hook that can be overridden in a subclass.
- From this hook, you can refresh expired tokens (or prompt a user for new ones), and resubscribe to the events.
-
- .. note::
-
- The topics will not be automatically resubscribed to. You must do it yourself by calling :meth:`~PubSubPool.subscribe_topics` with the topics after obtaining new tokens.
-
- An example of what this method should do:
-
- .. code:: python
-
- class MyPubSubPool(pubsub.PubSubPool):
- async def auth_fail_hook(self, topics: List[pubsub.Topic]):
- token = topics[0].token
- new_token = await some_imaginary_function_that_refreshes_tokens(token)
-
- for topic in topics:
- topic.token = new_token
-
- await self.subscribe_topics(topics)
-
- Parameters
- ----------
- topics: List[:class:`Topic`]
- The topics that have been deauthorized. Typically these will all contain the same token.
- """
-
- async def _process_reconnect_hook(self, node: PubSubWebsocket) -> None:
- topics = copy.copy(node.topics)
-
- for topic in topics:
- self._topics.pop(topic, None)
-
- try:
- new_topics = await self.reconnect_hook(node, topics)
- except Exception as e:
- new_topics = node.topics
- logger.error("Error occurred while calling reconnect_hook.", exc_info=e)
-
- for topic in new_topics:
- self._topics[topic] = node
-
- node.topics = new_topics
-
- async def reconnect_hook(self, node: PubSubWebsocket, topics: List[Topic]) -> List[Topic]:
- """
- This is a low-level hook that can be overridden in a subclass.
- it is called whenever a node has to reconnect for any reason, from the twitch edge lagging out to being told to by twitch.
- This hook allows you to modify the topics, potentially updating tokens or removing topics altogether.
-
- Parameters
- ----------
- node: :class:`PubSubWebsocket`
- The node that is reconnecting.
- topics: List[:class:`Topic`]
- The topics that this node has.
-
- Returns
- -------
- List[:class:`Topic`]
- The list of topics this node should have. Any additions, modifications, or removals will be respected.
- """
- return topics
-
- def _find_node(self, topics: List[Topic]) -> Optional[PubSubWebsocket]:
- if self._mode != "group":
- raise ValueError("group is the only supported mode.")
-
- for p in self._pool:
- if p.max_topics + len(topics) <= p.max_topics:
- return p
-
- if len(self._pool) < self._max_size:
- return None
- else:
- raise models.PoolFull(
- f"The pubsub pool has reached maximum topics. Unable to allocate a group of {len(topics)} topics."
- )
diff --git a/twitchio/ext/pubsub/topics.py b/twitchio/ext/pubsub/topics.py
deleted file mode 100644
index 0a8de4ce..00000000
--- a/twitchio/ext/pubsub/topics.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-import uuid
-from typing import Optional, List, Type
-
-
-__all__ = (
- "Topic",
- "bits",
- "bits_badge",
- "channel_points",
- "channel_subscriptions",
- "moderation_user_action",
- "whispers",
-)
-
-
-class _topic:
- __slots__ = "__topic__", "__args__"
-
- def __init__(self, topic: str, args: List[Type]):
- self.__topic__ = topic
- self.__args__ = args
-
- def __call__(self, token: str):
- cls = Topic(self.__topic__, self.__args__)
- cls.token = token
- return cls
-
- def copy(self):
- return self.__class__(self.__topic__, self.__args__)
-
-
-class Topic(_topic):
- """
- Represents a PubSub Topic. This should not be created manually,
- use the provided methods to create these.
-
- Attributes
- -----------
- token: :class:`str`
- The token to use to authorize this topic
- args: List[Union[:class:`int`, Any]]
- The arguments to substitute in to the topic string
- """
-
- __slots__ = "token", "args", "_nonce"
-
- def __init__(self, topic, args):
- super().__init__(topic, args)
- self.token = None
- self._nonce = None
- self.args = []
-
- def __getitem__(self, item):
- assert len(self.args) < len(self.__args__), ValueError("Too many arguments")
- assert isinstance(item, self.__args__[len(self.args)]), ValueError(
- f"Got {item!r}, excepted {self.__args__[len(self.args)]}"
- ) # noqa
- self.args.append(item)
- return self
-
- @property
- def present(self) -> Optional[str]:
- """
- Returns a websocket-ready topic string, if all the arguments needed have been provided.
- Otherwise returns ``None``
- """
- try:
- return self.__topic__.format(*self.args)
- except:
- return None
-
- def _present_set_nonce(self, nonce: str) -> Optional[str]:
- self._nonce = nonce
- return self.present
-
- def __eq__(self, other):
- return other is self or (isinstance(other, Topic) and other.present == self.present)
-
- def __hash__(self):
- return hash(self.present)
-
- def __repr__(self):
- return f""
-
-
-bits = _topic("channel-bits-events-v2.{0}", [int])
-bits_badge = _topic("channel-bits-badge-unlocks.{0}", [int])
-channel_points = _topic("channel-points-channel-v1.{0}", [int])
-channel_subscriptions = _topic("channel-subscribe-events-v1.{0}", [int])
-moderation_user_action = _topic("chat_moderator_actions.{0}.{1}", [int, int])
-whispers = _topic("whispers.{0}", [int])
diff --git a/twitchio/ext/pubsub/websocket.py b/twitchio/ext/pubsub/websocket.py
deleted file mode 100644
index c8bfcff9..00000000
--- a/twitchio/ext/pubsub/websocket.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""
-The MIT License (MIT)
-
-Copyright (c) 2017-present TwitchIO
-
-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.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import time
-import uuid
-from itertools import groupby
-from typing import Optional, List, TYPE_CHECKING
-
-import aiohttp
-
-from twitchio import Client
-from .topics import Topic
-from . import models
-
-if TYPE_CHECKING:
- from .pool import PubSubPool
-
-try:
- import ujson as json
-except:
- import json
-
-
-logger = logging.getLogger("twitchio.ext.pubsub.websocket")
-
-
-__all__ = ("PubSubWebsocket",)
-
-
-class PubSubWebsocket:
- __slots__ = (
- "session",
- "topics",
- "pool",
- "client",
- "connection",
- "_latency",
- "timeout",
- "_task",
- "_poll",
- "max_topics",
- "_closing",
- )
-
- ENDPOINT = "wss://pubsub-edge.twitch.tv"
-
- def __init__(self, client: Client, pool: PubSubPool, *, max_topics=50):
- self.max_topics = max_topics
- self.session = None
- self.connection: Optional[aiohttp.ClientWebSocketResponse] = None
- self.topics: List[Topic] = []
- self.pool = pool
- self.client = client
- self._latency = None
- self._closing = False
- self.timeout = asyncio.Event()
-
- @property
- def latency(self) -> Optional[float]:
- return self._latency
-
- async def connect(self):
- self.connection = None
- if self.session is None:
- self.session = aiohttp.ClientSession()
-
- logger.debug(f"Websocket connecting to {self.ENDPOINT}")
- backoff = 2
- for attempt in range(5):
- try:
- self.connection = await self.session.ws_connect(self.ENDPOINT)
- break
- except aiohttp.ClientConnectionError:
- logger.warning(f"Failed to connect to pubsub edge. Retrying in {backoff} seconds (attempt {attempt}/5)")
- await asyncio.sleep(backoff)
- backoff **= 2
-
- if not self.connection:
- raise models.ConnectionFailure("Failed to connect to pubsub edge")
-
- self._task = self.client.loop.create_task(self.ping_pong())
- self._poll = self.client.loop.create_task(self.poll())
- await self._send_initial_topics()
-
- async def disconnect(self):
- if not self.session or not self.connection or self.connection.closed:
- return
-
- await self.connection.close(code=1000)
- self._task.cancel()
- self._poll.cancel()
-
- async def reconnect(self):
- await self.disconnect()
- await self.pool._process_reconnect_hook(self)
- await self.connect()
-
- async def _send_initial_topics(self):
- await self._send_topics(self.topics)
-
- async def _send_topics(self, topics: List[Topic], type="LISTEN"):
- for tok, _topics in groupby(topics, key=lambda val: val.token):
- nonce = ("%032x" % uuid.uuid4().int)[:8]
-
- payload = {
- "type": type,
- "nonce": nonce,
- "data": {"topics": [x._present_set_nonce(nonce) for x in _topics], "auth_token": tok},
- }
- logger.debug(f"Sending {type} payload with nonce '{nonce}': {payload}")
- await self.send(payload)
-
- async def subscribe_topics(self, topics: List[Topic]):
- if len(self.topics) + len(topics) > self.max_topics:
- raise ValueError(f"Cannot have more than {self.max_topics} topics on one websocket")
-
- self.topics += topics
- if not self.connection or self.connection.closed:
- return
-
- await self._send_topics(topics)
-
- async def unsubscribe_topic(self, topics: List[Topic]):
- if any(t not in self.topics for t in topics):
- raise ValueError("Topics were given that have not been subscribed to")
-
- await self._send_topics(topics, type="UNLISTEN")
- for t in topics:
- self.topics.remove(t)
-
- async def poll(self):
- while not self.connection.closed:
- data = await self.connection.receive_json(loads=json.loads)
-
- handle = getattr(self, "handle_" + data["type"].lower().replace("-", "_"), None)
- if handle:
- self.client.loop.create_task(handle(data), name=f"pubsub-handle-event: {data['type']}")
- else:
- print(data)
- logger.debug(f"Pubsub event referencing unknown event '{data['type']}'. Discarding")
-
- if not self._closing:
- logger.warning("Unexpected disconnect from pubsub edge! Attempting to reconnect")
- self._task.cancel()
- await self.connect()
-
- async def ping_pong(self):
- while self.connection and not self.connection.closed:
- await asyncio.sleep(240)
- self.timeout.clear()
- await self.send({"type": "PING"})
- t = time.time()
- try:
- await asyncio.wait_for(self.timeout.wait(), 10)
- except asyncio.TimeoutError:
- await asyncio.shield(self.reconnect()) # we're going to get cancelled, so shield the coro
- else:
- self._latency = time.time() - t
-
- async def send(self, data: dict):
- data = json.dumps(data)
- await self.connection.send_str(data)
-
- async def handle_pong(self, _):
- self.timeout.set()
- self.client.run_event("pubsub_pong")
-
- async def handle_message(self, message: dict):
- message["data"]["message"] = json.loads(message["data"]["message"])
- msg = models.PubSubMessage(self.client, message["data"]["topic"], message["data"]["message"])
- self.client.run_event("pubsub_message", msg) # generic one
-
- self.client.run_event(*models.create_message(self.client, message))
-
- async def handle_reward_redeem(self, message: dict):
- msg = models.PubSubChannelPointsMessage(self.client, message["data"])
- self.client.run_event("pubsub_message", msg) # generic one
- self.client.run_event("pubsub_channel_points", msg)
-
- async def handle_response(self, message: dict):
- if message["error"]:
- logger.error(f"Received errored response for nonce {message['nonce']}: {message['error']}")
- self.client.run_event("pubsub_error", message)
- if message["error"] == "ERR_BADAUTH":
- nonce = message["nonce"]
- await self.pool._process_auth_fail(nonce, self)
-
- elif message["type"] == "RECONNECT":
- logger.warning("Received RECONNECT response from pubsub edge. Reconnecting")
- await asyncio.shield(self.reconnect())
- elif message["nonce"]:
- logger.debug(f"Received OK response for nonce {message['nonce']}")
- self.client.run_event("pubsub_nonce", message)
-
- async def handle_reconnect(self, message: dict):
- logger.warning("Received RECONNECT response from pubsub edge. Reconnecting")
- await asyncio.shield(self.reconnect())
diff --git a/twitchio/ext/routines/__init__.py b/twitchio/ext/routines/__init__.py
index 82abb3e8..bafe1d66 100644
--- a/twitchio/ext/routines/__init__.py
+++ b/twitchio/ext/routines/__init__.py
@@ -1,212 +1,354 @@
-# -*- coding: utf-8 -*-
-
"""
-The MIT License (MIT)
+MIT License
-Copyright (c) 2017-present TwitchIO
+Copyright (c) 2017 - Present PythonistaGuild
-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:
+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 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,
+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.
+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.
"""
+
+from __future__ import annotations
+
import asyncio
import datetime
-import sys
-import traceback
-from typing import Callable, Optional
+import logging
+from collections.abc import Callable, Coroutine
+from typing import Any, TypeAlias, TypeVar
+
+from twitchio.backoff import Backoff
__all__ = ("Routine", "routine")
+T = TypeVar("T")
+CoroT: TypeAlias = Callable[..., Coroutine[Any, Any, Any]]
+
+
+LOGGER: logging.Logger = logging.getLogger(__name__)
+
+
def compute_timedelta(dt: datetime.datetime) -> float:
if dt.tzinfo is None:
dt = dt.astimezone()
- now = datetime.datetime.now(datetime.timezone.utc)
+ now = datetime.datetime.now(tz=datetime.UTC)
return max((dt - now).total_seconds(), 0)
class Routine:
- """The main routine class which helps run async background tasks on a schedule.
+ """The TwitchIO Routine class which runs asynchronously in the background on a timed loop.
- Examples
- --------
- .. code:: py
+ .. note::
- @routine(seconds=5, iterations=3)
- async def test(arg):
- print(f'Hello {arg}')
+ You should not instantiate this class manually, instead use the :func:`routine` decorator instead.
- test.start('World!')
+ Examples
+ --------
- .. warning::
+ .. code:: python3
- This class should not be instantiated manually. Use the decorator :func:`routine` instead.
- """
+ # This routine will run every minute until stopped or canceled.
- def __init__(
- self,
- *,
- coro: Callable,
- iterations: Optional[int] = None,
- time: Optional[datetime.datetime] = None,
- delta: Optional[float] = None,
- wait_first: Optional[bool] = False,
- ):
- self._coro = coro
- self._task: asyncio.Task = None # type: ignore
+ @routines.routine(delta=datetime.timedelta(minutes=1))
+ async def my_routine() -> None:
+ print("Hello World!")
- self._time = time
- self._delta = delta
+ my_routine.start()
- self._start_time: datetime.datetime = None # type: ignore
+ .. code:: python3
- self._completed_loops = 0
+ # Pass some arguments to a routine...
- iterations = iterations if iterations != 0 else None
- self._iterations = iterations
- self._remaining_iterations = iterations
+ @routines.routine(delta=datetime.timedelta(minutes=1))
+ async def my_routine(hello: str) -> None:
+ print(f"Hello {hello}")
- self._before = None
- self._after = None
- self._error = None
+ my_routine.start("World!")
- self._stop_set = False
- self._restarting = False
- self._wait_first = wait_first
+ .. code:: python3
+
+ # Only run the routine three of times...
- self._stop_on_error = True
+ @routines.routine(delta=datetime.timedelta(minutes=1), iterations=3)
+ async def my_routine(hello: str) -> None:
+ print(f"Hello {hello}")
- self._instance = None
+ my_routine.start("World!")
- self._args: tuple | None = None
- self._kwargs: dict | None = None
+ """
- def __get__(self, instance, owner):
+ def __init__(
+ self,
+ *,
+ name: str | None = None,
+ coro: CoroT,
+ max_attempts: int | None = None,
+ iterations: int | None,
+ wait_remainder: bool = False,
+ stop_on_error: bool = False,
+ wait_first: bool,
+ time: datetime.datetime | None,
+ delta: datetime.timedelta | None,
+ ) -> None:
+ self._coro: CoroT = coro
+ self._name: str = name or f"twitchio.ext.routines: <{self.__class__.__qualname__}[{self._coro.__qualname__}]>"
+ self._task: asyncio.Task[None] | None = None
+ self._injected = None
+ self._time: datetime.datetime | None = time
+ self._original_delta: datetime.timedelta | None = delta
+ self._delta: float | None = delta.total_seconds() if delta else None
+
+ self._before_routine: CoroT | None = None
+ self._after_routine: CoroT | None = None
+ self._on_error: CoroT | None = None
+
+ self._stop_on_error: bool = stop_on_error
+ self._should_stop: bool = False
+ self._restarting: bool = False
+ self._wait_first: bool = wait_first
+
+ self._completed: int = 0
+ self._iterations: int | None = iterations
+ self._current_iteration: int = 0
+ self._wait_remainder: bool = wait_remainder
+ self._last_start: datetime.datetime | None = None
+
+ self._max_attempts: int | None = max_attempts
+
+ self._args: Any = ()
+ self._kwargs: Any = {}
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__qualname__}[{self._coro.__qualname__}]>"
+
+ def __get__(self, instance: T, type_: type[T]) -> Routine:
if instance is None:
return self
- copy = Routine(
+ copy: Routine = Routine(
coro=self._coro,
- iterations=self._iterations,
+ name=self._name,
time=self._time,
- delta=self._delta,
+ delta=self._original_delta,
+ max_attempts=self._max_attempts,
+ iterations=self._iterations,
+ wait_remainder=self._wait_remainder,
wait_first=self._wait_first,
+ stop_on_error=self._stop_on_error,
)
+ copy._injected = instance
+ copy._before_routine = self._before_routine
+ copy._after_routine = self._after_routine
+ copy._on_error = self._on_error
- copy._instance = instance
- copy._before = self._before
- copy._after = self._after
- copy._error = self._error
setattr(instance, self._coro.__name__, copy)
-
return copy
- def start(self, *args, **kwargs) -> asyncio.Task:
- """Start the routine and return the created task.
+ async def __call__(self, *args: Any, **kwargs: Any) -> Any:
+ if self._injected:
+ args = (self._injected, *args)
+
+ await self._coro(*args, **kwargs)
+
+ def _can_cancel(self) -> bool:
+ return self._task is not None and not self._task.done()
+
+ async def _call_error(self, error: Exception) -> None:
+ if self._on_error is None:
+ await self.on_error(error)
+ return
+
+ if self._injected is not None:
+ await self._on_error(self._injected, error)
+ else:
+ await self._on_error(error)
+
+ async def _routine_loop(self, *args: Any, **kwargs: Any) -> None:
+ backoff: Backoff = Backoff(base=3, maximum_time=10, maximum_tries=5)
+
+ if self._should_stop:
+ return
+
+ if self._before_routine:
+ try:
+ await self._before_routine(*args, **kwargs)
+ except Exception as e:
+ await self._call_error(e)
+ return self.cancel()
+
+ if self._wait_first:
+ if self._time:
+ await asyncio.sleep(compute_timedelta(self._time))
+ elif self._delta:
+ await asyncio.sleep(self._delta)
+
+ attempts: int | None = self._max_attempts
+ try:
+ while True:
+ self._last_start: datetime.datetime | None = datetime.datetime.now(tz=datetime.UTC)
+ self._current_iteration += 1
+
+ try:
+ await self._coro(*args, **kwargs)
+ except Exception as e:
+ await self._call_error(e)
+
+ if self._stop_on_error:
+ self._should_stop = False
+ break
+
+ if attempts is not None:
+ attempts -= 1
+
+ if attempts <= 0:
+ LOGGER.warning(
+ "The maximum retry attempts for Routine: %s has been reached and will now be canceled.",
+ self.__repr__(),
+ )
+ break
+
+ await asyncio.sleep(backoff.calculate())
+ continue
+
+ else:
+ attempts = self._max_attempts
+ self._completed += 1
+
+ if self.remaining_iterations is not None and self.remaining_iterations <= 0:
+ break
+
+ if self._should_stop:
+ self._should_stop = False
+ break
+
+ if self._time:
+ sleep = compute_timedelta(self._time + datetime.timedelta(days=self._current_iteration))
+ else:
+ assert self._delta is not None
+
+ if not self._wait_remainder:
+ sleep = self._delta
+ else:
+ maxxed = (self._last_start - datetime.datetime.now(tz=datetime.UTC)).total_seconds()
+ sleep = max(maxxed + self._delta, 0)
+
+ await asyncio.sleep(sleep)
+
+ except Exception as e:
+ msg = "A fatal error occured during an iteration of Routine: %s. This routine will now be canceled."
+ LOGGER.error(msg, self.__repr__(), exc_info=e)
+
+ if self._after_routine:
+ try:
+ await self._after_routine(*args, **kwargs)
+ except Exception as e:
+ await self._call_error(e)
+
+ self.cancel()
+
+ @property
+ def args(self) -> tuple[Any, ...]:
+ """Property returning any positional arguments passed to the routine via :meth:`.start`."""
+ return self._args
+
+ @property
+ def kwargs(self) -> Any:
+ """Property returning any keyword arguments passed to the routine via :meth:`.start`."""
+ return self._kwargs
+
+ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
+ r"""Method to start the :class:`~Routine` in the background.
+
+ .. note::
+
+ You can not start an already running task. See: :meth:`.restart` instead.
Parameters
----------
- stop_on_error: Optional[bool]
- Whether or not to stop and cancel the routine on error. Defaults to True.
- \*args
- The args to pass to the routine.
- \*\*kwargs
- The kwargs to pass to the routine.
+ *args: Any
+ Any positional args passed to this method will also be passed to your :class:`~Routine` callback.
+ **kwargs: Any
+ Any keyword arguments passed to this method will also be passed to your :class:`~Routine` callback.
Returns
-------
:class:`asyncio.Task`
- The created internal asyncio task.
-
- Raises
- ------
- RuntimeError
- Raised when this routine is already running when start is called.
+ The internal background task associated with this :class:`~Routine`.
"""
- if self._task is not None and not self._task.done() and not self._restarting:
- raise RuntimeError(f"Routine {self._coro.__name__!r} is already running and is not done.")
+ if self._task and not self._task.done() and not self._restarting:
+ raise RuntimeError(f"Routine {self!r} is currently running and has not completed.")
+
+ self._args = args
+ self._kwargs = kwargs
- self._args, self._kwargs = args, kwargs
+ if self._injected:
+ args = (self._injected, *args)
self._restarting = False
- self._task = asyncio.create_task(self._routine(*args, **kwargs))
- if not self._error:
- self._error = self.on_error
+ loop = self._routine_loop(*args, **kwargs)
+ self._task = asyncio.create_task(loop, name=self._name)
return self._task
def stop(self) -> None:
- """Stop the routine gracefully.
+ """Method to stop the currently running task after it completes its current iteration.
- .. note::
-
- This allows the current iteration to complete before the routine is cancelled.
- If immediate cancellation is desired consider using :meth:`cancel` instead.
+ Unlike :meth:`.cancel` this will schedule the task to stop after it completes its next iteration.
"""
- self._stop_set = True
+ self._should_stop = True
def cancel(self) -> None:
- """Cancel the routine effective immediately and non-gracefully.
+ """Method to immediately cancel the currently running :class:`~Routine`.
- .. note::
-
- Consider using :meth:`stop` if a graceful stop, which will complete the current iteration, is desired.
+ Unlike :meth:`.stop`, this method is not graceful and will cancel the task in its current iteration.
"""
- if self._can_be_cancelled():
+ if self._can_cancel():
+ assert self._task is not None
self._task.cancel()
if not self._restarting:
self._task = None
- def restart(self, *args, **kwargs) -> None:
- """Restart the currently running routine.
+ def restart(self, *, force: bool = True) -> None:
+ """Method which restarts the :class:`~Routine`.
+
+ If the :class:`~Routine` has not yet been started, this method immediately returns.
Parameters
----------
- stop_on_error: Optional[bool]
- Whether or not to stop and cancel the routine on error. Defaults to True.
- force: Optional[bool]
- If True the restart will cancel the currently running routine effective immediately and restart.
- If False a graceful stop will occur, which allows the routine to finish it's current iteration.
- Defaults to True.
- \*args
- The args to pass to the routine.
- \*\*kwargs
- The kwargs to pass to the routine.
-
-
- .. note::
-
- This does not return the internal task unlike :meth:`start`.
+ force: bool
+ Whether to cancel the currently running routine and restart it immediately. If this is ``False`` the
+ :class:`~Routine` will start after it's current iteration. Defaults to ``True``.
"""
- force = kwargs.pop("force", True)
self._restarting = True
+ self._current_iteration = 0
- self._remaining_iterations = self._iterations
+ if not self._task:
+ return
- def restart_when_over(fut, *, args=args, kwargs=kwargs):
- self._task.remove_done_callback(restart_when_over)
- self.start(*args, **kwargs)
+ def restart_when_over(fut: asyncio.Task[None]) -> None:
+ fut.remove_done_callback(restart_when_over)
+ self.start(*self._args, **self._kwargs)
- if self._can_be_cancelled():
+ if self._can_cancel():
self._task.add_done_callback(restart_when_over)
if force:
@@ -214,264 +356,199 @@ def restart_when_over(fut, *, args=args, kwargs=kwargs):
else:
self.stop()
- def before_routine(self, coro: Callable) -> None:
- """A decorator to assign a coroutine to run before the routine starts."""
- if not asyncio.iscoroutinefunction(coro):
- raise TypeError(f"Expected coroutine function not type, {type(coro).__name__!r}.")
+ def before_routine(self, func: CoroT) -> None:
+ """|deco|
- self._before = coro
+ Decorator used to set a coroutine to run before the :class:`~Routine` has started.
- def after_routine(self, coro: Callable) -> None:
- """A decorator to assign a coroutine to run after the routine has ended."""
- if not asyncio.iscoroutinefunction(coro):
- raise TypeError(f"Expected coroutine function not type, {type(coro).__name__!r}.")
+ Any arguments passed to :meth:`.start` will also be passed to this coroutine callback.
+ """
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError(f'"before_routine" for {self!r} expected a coroutine function not {type(func).__name__!r}')
- self._after = coro
+ if self._before_routine is not None:
+ LOGGER.warning("The before_routine for %s has previously been set.", self.__repr__())
- def change_interval(
- self,
- *,
- seconds: Optional[float] = 0,
- minutes: Optional[float] = 0,
- hours: Optional[float] = 0,
- time: Optional[datetime.datetime] = None,
- wait_first: Optional[bool] = False,
- ) -> None:
- """Method which schedules the running interval of the task to change.
+ self._before_routine = func
- Parameters
- ----------
- seconds: Optional[float]
- The seconds to wait before the next iteration of the routine.
- minutes: Optional[float]
- The minutes to wait before the next iteration of the routine.
- hours: Optional[float]
- The hours to wait before the next iteration of the routine.
- time: Optional[datetime.datetime]
- A specific time to run this routine at. If a naive datetime is passed, your system local time will be used.
- wait_first: Optional[bool]
- Whether to wait the specified time before running the first iteration at the new interval.
- Defaults to False.
-
- Raises
- ------
- RuntimeError
- Raised when the time argument and any hours, minutes or seconds is passed together.
-
-
- .. warning::
-
- The time argument can not be passed in conjunction with hours, minutes or seconds.
- This behaviour is intended as it allows the time to be exact every day.
- """
- time_ = time
+ def after_routine(self, func: CoroT) -> None:
+ """|deco|
- if any((seconds, minutes, hours)) and time_:
- raise RuntimeError(
- "Argument