diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 12f1880d..86f9f85d 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms -github: [IAmTomahawkx, chillymosh] +github: [EvieePy, chillymosh] diff --git a/.github/dependabot.yaml b/.github/dependabot.yml similarity index 100% rename from .github/dependabot.yaml rename to .github/dependabot.yml diff --git a/.github/workflows/build.yml b/.github/workflows/coverage_lint_build.yml similarity index 67% rename from .github/workflows/build.yml rename to .github/workflows/coverage_lint_build.yml index 4a581157..44f07ae8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/coverage_lint_build.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.10", "3.11"] + python-version: ["3.11", "3.x"] steps: - name: Checkout @@ -28,10 +28,10 @@ jobs: - name: Install deps run: | pip install -U wheel setuptools pip Cython - pip install '.[speed,sound]' + pip install '.[starlette]' - name: Build wheels - run: pip wheel -w ./wheelhouse/ '.[speed,sound]' + run: pip wheel -w ./wheelhouse/ '.[starlette]' - uses: actions/upload-artifact@v4 with: @@ -62,16 +62,13 @@ jobs: - name: Install CPython uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: 3.11 - name: Install Deps run: | sudo apt update - sudo apt install -y libasound-dev portaudio19-dev libportaudio2 libportaudiocpp0 python -m ensurepip - pip install -r docs/requirements.txt - pip install -r requirements.txt - + pip install -U '.[starlette,docs]' - name: Build Docs run: | cd docs @@ -79,23 +76,41 @@ jobs: lint: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.x"] + + name: "Type Coverage and Linting @ ${{ matrix.python-version }}" steps: - - name: checkout + - name: "Checkout Repository" uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Setup Python + - name: "Setup Python @ ${{ matrix.python-version }}" + id: setup-python uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: "${{ matrix.python-version }}" + cache: "pip" - - name: install black + - name: "Install Python deps @ ${{ matrix.python-version }}" run: | - python -m ensurepip - pip install black + pip install -Ur requirements.txt starlette uvicorn + + - name: "Run Pyright @ ${{ matrix.python-version }}" + uses: jakebailey/pyright-action@v2 + with: + annotate: ${{ matrix.python-version != '3.x' }} + warnings: false + + - name: Lint with Ruff + uses: astral-sh/ruff-action@v3 - - name: run linter + - name: Check formatting with Ruff run: | - black twitchio --line-length 120 --verbose --check + ruff format --check upload_pypi: if: github.event_name == 'push' && github.ref_type == 'tag' diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml deleted file mode 100644 index 60bb62e9..00000000 --- a/.github/workflows/issues.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: issue-welcome - -on: - issues: - types: [opened] - -jobs: - comment: - runs-on: ubuntu-latest - - steps: - - uses: ben-z/actions-comment-on-issue@1.0.3 - with: - message: "Hello! Thanks for the issue. If this is a general help question, for a faster response consider joining the official [Discord Server](https://discord.gg/RAKc3HF)\n\nElse if you have an issue with the library please wait for someone to help you here." - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/signoff.yaml b/.github/workflows/signoff.yml similarity index 100% rename from .github/workflows/signoff.yaml rename to .github/workflows/signoff.yml diff --git a/.gitignore b/.gitignore index 05a92a18..43ea658e 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,8 @@ $RECYCLE.BIN/ ### Casual test files testing.py testing/ +test*.py + +# So we don't accidentally commit tokens while testing... +.tio.tokens.json +TOKENS.py \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml index 059b330e..ec8c02a7 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,18 +1,12 @@ -formats: [] - version: 2 -build: - os: ubuntu-20.04 - tools: {"python": "3.7"} +sphinx: + configuration: docs/conf.py - apt_packages: - - libasound-dev - - portaudio19-dev - - libportaudio2 - - libportaudiocpp0 - - ffmpeg - - python3-pyaudio +build: + os: ubuntu-22.04 + tools: + python: "3.12" python: install: @@ -21,3 +15,4 @@ python: path: . extra_requirements: - docs + - starlette \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..216c38dc --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +> [!IMPORTANT] +> Version **3** is currently a BETA release. + + +![](https://raw.githubusercontent.com/TwitchIO/TwitchIO/master/logo.png) +[![](https://img.shields.io/badge/Python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org) +![Pyright Strict](https://img.shields.io/badge/Pyright-Strict-b8dbb4) +![GitHub License](https://img.shields.io/github/license/PythonistaGuild/twitchio) + + +### TwitchIO is a powerful, asynchronous Python library for [twitch.tv](https://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. + +**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`` + +### Documentation +[Documentation](https://twitchio.dev/) + +Getting Started +-------------------------------- +[Installing](https://twitchio.dev/en/getting-started/installing.html) + +[Quickstart](https://twitchio.dev/en/getting-started/quickstart.html) + +[Examples](/examples) + +### Support +For support using TwitchIO, please join the official [support server](https://discord.gg/RAKc3HF) on [Discord](https://discord.com/) + +[![Discord Banner](https://discordapp.com/api/guilds/490948346773635102/widget.png?style=banner2)](https://discord.gg/RAKc3HF) + diff --git a/README.rst b/README.rst deleted file mode 100644 index ceafdbcb..00000000 --- a/README.rst +++ /dev/null @@ -1,121 +0,0 @@ -.. image:: https://raw.githubusercontent.com/TwitchIO/TwitchIO/master/logo.png - :align: center - - -.. image:: https://img.shields.io/badge/Python-3.7%20%7C%203.8%20%7C%203.9-blue.svg - :target: https://www.python.org - - -.. image:: https://img.shields.io/github/license/TwitchIO/TwitchIO.svg - :target: ./LICENSE - - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - -.. image:: https://img.shields.io/pypi/dm/twitchio?color=black - :target: https://pypi.org/project/twitchio - :alt: PyPI - Downloads - - -TwitchIO is an asynchronous Python wrapper around the Twitch API and IRC, with a powerful command extension for creating Twitch Chat Bots. TwitchIO covers almost all of the new Twitch API and features support for commands, PubSub, Webhooks, and EventSub. - -Documentation ---------------------------- -For the Official Documentation: `Click Here! `_ - -Support ---------------------------- -For support using TwitchIO, please join the official `support server -`_ on `Discord `_. - -|Discord| - -.. |Discord| image:: https://img.shields.io/discord/490948346773635102?color=%237289DA&label=Pythonista&logo=discord&logoColor=white - :target: https://discord.gg/RAKc3HF - -Installation ---------------------------- -TwitchIO requires **Python 3.7+**. You can download the latest version of Python `here `_. - -**Windows** - -.. code:: sh - - py -m pip install -U twitchio - -**Linux** - -.. code:: sh - - python -m pip install -U twitchio - -Access Tokens ---------------------------- -Visit `Token Generator `_ for a simple way to generate tokens for use with TwitchIO. - -Getting Started ---------------------------- -A simple Chat Bot. - -.. 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... - super().__init__(token='ACCESS_TOKEN', prefix='?', initial_channels=['...']) - - async def event_ready(self): - # 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): - # Send a hello back! - await ctx.send(f'Hello {ctx.author.name}!') - - - bot = Bot() - bot.run() - - -Contributing ---------------------------- -TwitchIO currently uses the `Black `_ formatter to enforce sensible style formatting. - - -Before creating a Pull Request it is encouraged you install and run black on your code. - -The Line Length limit for TwitchIO is **120**. - - -For installation and usage of Black visit: `Black Formatter `_ - -For integrating Black into your IDE visit: `Black IDE Usage `_ - -Special Thanks ---------------------------- -Thank you to all those who contribute and help TwitchIO grow. - -Special thanks to: - -`LostLuma (Lilly) `_ - -`Harmon `_ - -`Tom `_ - -`Tesence `_ - -`Adure `_ - -`Scragly `_ - -`Chillymosh `_ - -If I have forgotten anyone please let me know <3: `EvieePy `_ diff --git a/docs/Makefile b/docs/Makefile index d4bb2cbb..41c270bb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/extensions/attributetable.py b/docs/_extensions/attributetable.py similarity index 67% rename from docs/extensions/attributetable.py rename to docs/_extensions/attributetable.py index 6fa74110..a225eb33 100644 --- a/docs/extensions/attributetable.py +++ b/docs/_extensions/attributetable.py @@ -1,13 +1,22 @@ -from sphinx.util.docutils import SphinxDirective -from sphinx.locale import _ -from docutils import nodes -from sphinx import addnodes +from __future__ import annotations -from collections import OrderedDict, namedtuple import importlib import inspect -import os import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, NamedTuple + +from docutils import nodes +from sphinx import addnodes +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx.locale import _ +from sphinx.util.docutils import SphinxDirective +from sphinx.util.typing import OptionSpec + + +if TYPE_CHECKING: + from .builder import DPYHTML5Translator class attributetable(nodes.General, nodes.Element): @@ -34,19 +43,20 @@ class attributetable_item(nodes.Part, nodes.Element): pass -def visit_attributetable_node(self, node): - self.body.append('
' % node["python-class"]) +def visit_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: + class_ = node["python-class"] + self.body.append(f'
') -def visit_attributetablecolumn_node(self, node): +def visit_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: self.body.append(self.starttag(node, "div", CLASS="py-attribute-table-column")) -def visit_attributetabletitle_node(self, node): +def visit_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: self.body.append(self.starttag(node, "span")) -def visit_attributetablebadge_node(self, node): +def visit_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: attributes = { "class": "py-attribute-table-badge", "title": node["badge-type"], @@ -54,27 +64,27 @@ def visit_attributetablebadge_node(self, node): self.body.append(self.starttag(node, "span", **attributes)) -def visit_attributetable_item_node(self, node): +def visit_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: self.body.append(self.starttag(node, "li", CLASS="py-attribute-table-entry")) -def depart_attributetable_node(self, node): +def depart_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: self.body.append("
") -def depart_attributetablecolumn_node(self, node): +def depart_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: self.body.append("
") -def depart_attributetabletitle_node(self, node): +def depart_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: self.body.append("") -def depart_attributetablebadge_node(self, node): +def depart_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: self.body.append("") -def depart_attributetable_item_node(self, node): +def depart_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: self.body.append("") @@ -86,10 +96,13 @@ class PyAttributeTable(SphinxDirective): required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False - option_spec = {} + option_spec: OptionSpec = {} - def parse_name(self, content): - path, name = _name_parser_regex.match(content).groups() + def parse_name(self, content: str) -> tuple[str, str]: + match = _name_parser_regex.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: @@ -97,12 +110,13 @@ def parse_name(self, content): if not modulename: modulename = self.env.ref_context.get("py:module") if modulename is None: - raise RuntimeError("modulename somehow None for %s in %s." % (content, self.env.docname)) + raise RuntimeError(f"modulename somehow None for {content} in {self.env.docname}.") return modulename, name - def run(self): + def run(self) -> list[attributetableplaceholder]: """If you're curious on the HTML this is meant to generate: +
_('Attributes') @@ -122,6 +136,7 @@ def run(self):
+ 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. - -
    -
  • For help with installing visit: Installing -
  • Your first steps with the library: Quickstart -
  • Frequently asked questions: FAQ -
- -

API Reference

-
- The API References of TwitchIO. - -
-
- TwitchIO Client Reference: - -
- -
- TwitchIO Extension API's: - -
-
- -

Getting Help

-
- 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 -

Changelog

-
- Keep upto date with the changelog.

+ references/client + references/events + references/user + references/eventsub_subscriptions + references/web + references/exceptions -.. rst-class:: index-changelog .. toctree:: - :maxdepth: 2 - :caption: Changelog + :maxdepth: 1 + :caption: Extension References - changelog + exts/commands/index + exts/routines/index + exts/sounds/index +.. toctree:: + :maxdepth: 1 + :caption: Models -.. raw:: html + references/eventsub_models + references/helix_models -

Table of Contents

-
+.. 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