Initial Commit

Initial commit of Code Base.
This commit is contained in:
Mario Steele 2025-06-12 14:31:14 -05:00
parent 293b1213e1
commit c11a4ebbc2
653 changed files with 36893 additions and 1 deletions

2
.gitignore vendored
View file

@ -414,4 +414,4 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
.idea/

View file

@ -0,0 +1,22 @@
using Godot;
namespace PokePurple.Library.Singletons;
public class GifManager
{
private static GifManager? _instance;
public static GifManager Instance => _instance ?? (_instance = new GifManager());
private Node _gifManager;
public GifManager()
{
_gifManager = ((SceneTree)Engine.GetMainLoop()).GetRoot().GetNode("GifManager");
}
public AnimatedTexture AnimatedTextureFromBuffer(byte[] data, int max_frames = 0) => _gifManager.Call("animated_texture_from_buffer", data, max_frames).As<AnimatedTexture>();
public AnimatedTexture AnimatedTextureFromFile(string file, int max_frames = 0) => _gifManager.Call("animated_texture_from_file", file, max_frames).As<AnimatedTexture>();
public SpriteFrames SpriteFramesFromBuffer(byte[] data, int max_frames = 0) => _gifManager.Call("sprite_frames_from_buffer", data, max_frames).As<SpriteFrames>();
public SpriteFrames SpriteFramesFromFile(string file, int max_frames = 0) => _gifManager.Call("sprite_frames_from_file", file, max_frames).As<SpriteFrames>();
}

View file

@ -0,0 +1 @@
uid://dphxqo51pj8i7

View file

@ -0,0 +1,12 @@
using Godot;
using System;
using Godot.Sharp.Extras;
public partial class Globals : Node
{
[NodePath] private Node _twitchService;
public override void _Ready()
{
this.OnReady();
}
}

View file

@ -0,0 +1 @@
uid://6nrfqrw53c1s

View file

@ -0,0 +1,47 @@
[gd_scene load_steps=12 format=3 uid="uid://b8whblp2hgrp"]
[ext_resource type="Script" uid="uid://6nrfqrw53c1s" path="res://Library/Singletons/Globals.cs" id="1_gsl6h"]
[ext_resource type="Script" uid="uid://i8st3lv0lidh" path="res://addons/twitcher/twitch_service.gd" id="1_ocehi"]
[ext_resource type="Script" uid="uid://dcrliedgr6eol" path="res://addons/twitcher/lib/oOuch/crypto_key_provider.gd" id="2_gsl6h"]
[ext_resource type="Script" uid="uid://00xbijwpi8xa" path="res://addons/twitcher/lib/oOuch/oauth_setting.gd" id="3_amvha"]
[ext_resource type="Script" uid="uid://b3n3et8mebjcc" path="res://addons/twitcher/auth/twitch_oauth_scopes.gd" id="4_fcooh"]
[ext_resource type="Resource" uid="uid://c4scwuk8q0r40" path="res://addons/twitcher/lib/oOuch/default_key_provider.tres" id="5_65rqk"]
[ext_resource type="Script" uid="uid://b52xp7c23ucfk" path="res://addons/twitcher/lib/oOuch/oauth_token.gd" id="6_lppa0"]
[sub_resource type="Resource" id="Resource_54bgt"]
script = ExtResource("2_gsl6h")
encrpytion_secret_location = "user://encryption_key.cfg"
[sub_resource type="Resource" id="Resource_km1mv"]
script = ExtResource("3_amvha")
redirect_url = "http://localhost:7170"
well_known_url = ""
token_url = "https://id.twitch.tv/oauth2/token"
authorization_url = "https://id.twitch.tv/oauth2/authorize"
device_authorization_url = "https://id.twitch.tv/oauth2/device"
cache_file = "user://auth.conf"
client_id = "xqh437vs83im368db89iwflm130ufb"
authorization_flow = 0
_encryption_key_provider = SubResource("Resource_54bgt")
client_secret = "D8bwpU1vz2zu6P9l0D3ktvctJMCuFspuG//RPTsyP8w="
[sub_resource type="Resource" id="Resource_to4h6"]
script = ExtResource("4_fcooh")
used_scopes = Array[StringName]([&"moderation:read", &"user:bot", &"user:read:chat", &"user:write:chat", &"chat:edit", &"chat:read"])
metadata/_custom_type_script = "uid://b3n3et8mebjcc"
[sub_resource type="Resource" id="Resource_3e7ys"]
script = ExtResource("6_lppa0")
_crypto_key_provider = ExtResource("5_65rqk")
_identifier = "EditorToken"
_cache_path = "user://auth.conf"
[node name="Globals" type="Node"]
script = ExtResource("1_gsl6h")
[node name="TwitchService" type="Node" parent="."]
script = ExtResource("1_ocehi")
oauth_setting = SubResource("Resource_km1mv")
scopes = SubResource("Resource_to4h6")
token = SubResource("Resource_3e7ys")
metadata/_custom_type_script = "uid://i8st3lv0lidh"

11
PokePurple.csproj Normal file
View file

@ -0,0 +1,11 @@
<Project Sdk="Godot.NET.Sdk/4.4.1">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GodotSharpExtras" Version="0.5.0" />
<PackageReference Include="PokeApiNet" Version="4.0.0" />
</ItemGroup>
</Project>

19
PokePurple.sln Normal file
View file

@ -0,0 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PokePurple", "PokePurple.csproj", "{5C940B04-31D7-4268-9B7E-63D5C488E573}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportRelease|Any CPU = ExportRelease|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5C940B04-31D7-4268-9B7E-63D5C488E573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5C940B04-31D7-4268-9B7E-63D5C488E573}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5C940B04-31D7-4268-9B7E-63D5C488E573}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
{5C940B04-31D7-4268-9B7E-63D5C488E573}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
{5C940B04-31D7-4268-9B7E-63D5C488E573}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
{5C940B04-31D7-4268-9B7E-63D5C488E573}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 B0TLANNER Games
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.

139
addons/godotgif/README.md Normal file
View file

@ -0,0 +1,139 @@
# Godot GIF
<br>
<p align="center">
<img src="./docs-images/logo.gif" alt="Logo" width="128" height="128" />
</p>
<p align="center">
<a href="https://github.com/BOTLANNER/godot-gif/actions/workflows/build_releases.yml"><img alt="GitHub Build" src="https://github.com/BOTLANNER/godot-gif/actions/workflows/build_releases.yml/badge.svg" height="20"/></a>
<a href="https://github.com/BOTLANNER/godot-gif/blob/develop/LICENSE.txt"><img alt="MIT License" src="https://img.shields.io/github/license/BOTLANNER/godot-gif" height="20"/></a>
</p>
## Description
GDExtension for Godot 4+ to load GIF files as [AnimatedTexture](https://docs.godotengine.org/en/stable/classes/class_animatedtexture.html) and/or [SpriteFrames](https://docs.godotengine.org/en/stable/classes/class_spriteframes.html).
NOTE: ~~**AnimatedTexture**~~ has been marked as deprecated according to development docs and could be removed in a future version of Godot.
## Usage
### Editor
Gif files can be imported at edit time as one of the supported types via Import options.
<details open>
<summary>Editor Imports Options</summary>
![Editor Imports Options](./docs-images/EditorImportSettings.gif)
</details>
See the [Editor Imports](./demo/editor_imports_example.tscn) example scene.
<details open>
<summary>Editor Imports Example</summary>
![Editor Imports](./docs-images/EditorImports.gif)
</details>
<hr/>
### Runtime
Gif files can be loaded at runtime as one of the supported types via the `GifManager` singleton.
`GifManager` exposes the following methods for loading gifs either from file or from bytes directly:
![GifManager Methods](./docs-images/methods.png)
e.g. to load from file
```py
get_node("AnimFromRuntimeFile").texture = GifManager.animated_texture_from_file("res://examples/file/optic.gif")
get_node("AnimatedSprite2RuntimeFile").sprite_frames = GifManager.sprite_frames_from_file("res://examples/file/optic.gif")
```
See the [Runtime Imports](./demo/main.tscn) example scene.
<details open>
<summary>Runtime Imports Example</summary>
![Runtime Imports](./docs-images/RuntimeImports.gif)
</details>
## Installation
Download the `gdextension` artifact from the [latest successful build](https://github.com/BOTLANNER/godot-gif/actions/workflows/build_releases.yml). (It should be right at the bottom of the **Summary**)
![image](https://github.com/BOTLANNER/godot-gif/assets/16349308/f28867c6-f669-45f2-9309-dbb17cec2031)
Extract the contents to your Godot project directory.
You should have an `addons` directory at the root with the following structure:
```bash
└───addons
└───godotgif
│ godotgif.gdextension
│ LICENSE.txt
│ README.md
└───bin
│ godotgif.windows.template_debug.x86_32.dll
│ godotgif.windows.template_debug.x86_64.dll
│ godotgif.windows.template_release.x86_32.dll
│ godotgif.windows.template_release.x86_64.dll
│ libgodotgif.android.template_debug.arm64.so
│ libgodotgif.android.template_release.arm64.so
│ libgodotgif.linux.template_debug.x86_32.so
│ libgodotgif.linux.template_debug.x86_64.so
│ libgodotgif.linux.template_release.x86_32.so
│ libgodotgif.linux.template_release.x86_64.so
├───godotgif.macos.template_debug.framework
│ libgodotgif.macos.template_debug
└───godotgif.macos.template_release.framework
libgodotgif.macos.template_release
```
Open your project. Any exisitng gifs should auto-import. New gifs in the project directory will automatically import as `SpriteFrames`. To convert them into `AnimatedTexture`, update the [import settings](#editor).
The `GifManager` class should also now be available for access within GDScript.
## Contributing
### Setup
Ensure **SCons** is setup. Refer to [Introduction to the buildsystem](https://docs.godotengine.org/en/stable/contributing/development/compiling/introduction_to_the_buildsystem.html)
* If using a different version of Godot, be sure to dump the bindings e.g.
```sh
godot --dump-extension-api extension_api.json
```
* Compile with
```sh
scons platform=<platform> custom_api_file=extension_api.json
```
### Debugging
This repository is configured for use with [VSCode](https://code.visualstudio.com/)
[Launch configurations](./.vscode/launch.json) have been setup for both debugging in editor and in runtime provided certain **VSCode** extensions are present and environment variables are defined.
The following environment variables are required:
1. `GODOT_PATH` - The directory in which Godot is installed
1. `GODOT_EXECUTABLE` - The executable name of the Godot installation
### More Details
Refer to [GDExtension C++ example](https://docs.godotengine.org/en/stable/tutorials/scripting/gdextension/gdextension_cpp_example.html)
## License
Unless otherwise specified, the extension is released under the
[MIT license](LICENSE.txt).
See the full list of third-party libraries with their licenses used by this
extension at [src/thirdparty/README.md](src/thirdparty/README.md).
This implementation heavily borrowed inspiration from the [gif module](https://github.com/goostengine/goost/tree/gd3/modules/gif) for [Goost](https://github.com/goostengine/goost) that is currently only based on Godot 3

View file

@ -0,0 +1,23 @@
[configuration]
entry_symbol = "godot_gif_library_init"
compatibility_minimum = "4.4"
[libraries]
macos.debug = "bin/godotgif.macos.template_debug.framework/libgodotgif.macos.template_debug"
macos.release = "bin/godotgif.macos.template_release.framework/libgodotgif.macos.template_release"
windows.debug.x86_32 = "bin/godotgif.windows.template_debug.x86_32.dll"
windows.release.x86_32 = "bin/godotgif.windows.template_release.x86_32.dll"
windows.debug.x86_64 = "bin/godotgif.windows.template_debug.x86_64.dll"
windows.release.x86_64 = "bin/godotgif.windows.template_release.x86_64.dll"
linux.debug.x86_64 = "bin/libgodotgif.linux.template_debug.x86_64.so"
linux.release.x86_64 = "bin/libgodotgif.linux.template_release.x86_64.so"
linux.debug.arm64 = "bin/libgodotgif.linux.template_debug.arm64.so"
linux.release.arm64 = "bin/libgodotgif.linux.template_release.arm64.so"
linux.debug.rv64 = "bin/libgodotgif.linux.template_debug.rv64.so"
linux.release.rv64 = "bin/libgodotgif.linux.template_release.rv64.so"
android.debug.x86_64 = "bin/libgodotgif.android.template_debug.x86_64.so"
android.release.x86_64 = "bin/libgodotgif.android.template_release.x86_64.so"
android.debug.arm64 = "bin/libgodotgif.android.template_debug.arm64.so"
android.release.arm64 = "bin/libgodotgif.android.template_release.arm64.so"

View file

@ -0,0 +1 @@
uid://xpotkvhtirqd

View file

@ -0,0 +1 @@
Copyright Kemomi 2024 - 2025. All rights reserved.

21
addons/twitcher/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kani
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.

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="5.878" cy="8" r="4.725" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.06125;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><circle cx="14.06" cy="1.677" r="1.318" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.20539;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><circle cx="14.06" cy="8" r="1.318" style="font-variation-settings:normal;vector-effect:none;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.20539;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><circle cx="14.06" cy="14.323" r="1.318" style="font-variation-settings:normal;vector-effect:none;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.20539;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><path d="M7.374 3.724 9.827 1.6h4.268" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.06125;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000;stop-opacity:1"/><path d="m7.353 12.348 2.474 2.105h4.268" style="font-variation-settings:normal;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.06129;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000"/><path d="M10.258 8.02 14.122 8" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.06125;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://sp45xp1nleuk"
path="res://.godot/imported/api-icon.svg-16588d9bd32b7709dca1da03b9d3c51f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/api-icon.svg"
dest_files=["res://.godot/imported/api-icon.svg-16588d9bd32b7709dca1da03b9d3c51f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="none" stroke="#e0e0e0" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.5" d="M1.7 2h13v9.1H7.5l-2.9 2.6v-2.6H1.8z" style="stroke-width:1;stroke-dasharray:none"/><path d="M3.305 4.018v4.575a.356.356 45 0 0 .356.356h1.573a.56.56 147.482 0 0 .506-.323l.76-1.632h2.973l.468 1.322h1.268l.55-1.322h1.008a.356.356 135 0 0 .356-.356V5.96a.356.356 45 0 0-.356-.356H6.5l-.759-1.62a.56.56 32.441 0 0-.507-.322H3.66a.356.356 135 0 0-.356.356m1.113.779h.399a.45.45 34.142 0 1 .42.284l.496 1.246v.002l.002.004h-.002l-.531 1.332a.315.315 145.858 0 1-.292.199H4.51a.417.417 45 0 1-.417-.418V5.121a.324.324 135 0 1 .324-.324" style="fill:#e0e0e0"/></svg>

After

Width:  |  Height:  |  Size: 747 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://1kwo4knd8h05"
path="res://.godot/imported/auth-icon.svg-8c3337a2de3e7408dd8b0185710eb409.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/auth-icon.svg"
dest_files=["res://.godot/imported/auth-icon.svg-8c3337a2de3e7408dd8b0185710eb409.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="none" stroke="#e0e0e0" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.5" d="M1.7 2h13v9.1H7.5l-2.9 2.6v-2.6H1.8z"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://txhld57vfpmo"
path="res://.godot/imported/chat-icon.svg-e1164fbfcc337348789c82b706287c92.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/chat-icon.svg"
dest_files=["res://.godot/imported/chat-icon.svg-e1164fbfcc337348789c82b706287c92.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="none" stroke="#e0e0e0" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.5" d="m8 1.633 2 2.553-2 6.188-2-6.188Z" style="fill:#e0e0e0;fill-opacity:1"/><circle cx="8" cy="13.494" r="1.57" style="fill:#e0e0e0;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 338 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dr6bv6l3g4as3"
path="res://.godot/imported/command-icon.svg-29aa6e8d352722ad63fdf993d5c2472a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/command-icon.svg"
dest_files=["res://.godot/imported/command-icon.svg-29aa6e8d352722ad63fdf993d5c2472a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Twitcher - Login Failed</title>
<script>setTimeout(() => window.close(), 5000);</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #2d2d2d, #1e1e1e);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: white;
}
.error-container {
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 15px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
max-width: 400px;
}
.error-icon {
font-size: 60px;
margin-bottom: 15px;
color: #e63946;
}
.error-message {
font-size: 24px;
font-weight: bold;
color: #ff6b6b;
}
.error-description {
font-size: 18px;
margin-top: 10px;
text-align: justify;
color: #e0e0e0;
}
.button {
display: inline-block;
cursor: pointer;
margin-top: 20px;
padding: 12px 25px;
background-color: #e63946;
color: white;
text-decoration: none;
font-weight: bold;
border-radius: 5px;
transition: background 0.3s ease, transform 0.2s ease;
}
.button:hover {
background-color: #d62839;
transform: scale(1.05);
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-message">Login Failed</div>
<div class="error-description">Login attempt was unsuccessful. Page should automatically close when it doesn't happen close it manually.</div>
<a onclick="window.close()" class="button">Close Page</a>
</div>
</body>
</html>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8.498 2.047c-.117-.015-4.11-.5-4.478 4.18A8 8 0 0 0 4 6.54c-.104 2.13.165 5.366-2.07 6.457a.6.6 0 0 0-.145.037q-.006.004-.008.008 0 .004.004.008c.176.146 6.066.061 6.262.058 1.508.023 5.666.065 6.115-.043a.1.1 0 0 0 .037-.015q.005-.004.004-.008c-.004-.013-2.036-.01-2.209-5.447h0" style="opacity:1;fill:none;stroke:#e0e0e0;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stop-color:#000;stop-opacity:1"/><path d="M9.873 13.742a1.928 2.01 0 0 1-.964 1.74 1.928 2.01 0 0 1-1.928 0 1.928 2.01 0 0 1-.964-1.74h1.928Z" style="font-variation-settings:normal;opacity:1;fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/><circle cx="11.411" cy="4.089" r="2.22" style="font-variation-settings:normal;opacity:1;fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://co7dy71iroidu"
path="res://.godot/imported/event-icon.svg-ae206972b8adb971cf3c156d8f980130.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/event-icon.svg"
dest_files=["res://.godot/imported/event-icon.svg-ae206972b8adb971cf3c156d8f980130.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M5.484 2.484v9.594M8 2.484v9.594M10.516 2.484v9.594" style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:2;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/><path d="m2.818 10.352.022 4.22h10.342v-3.955" style="fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/><path d="m.972 3.57 2.245 2.883L.972 9.337M13.18 3.393l2.245 2.883L13.18 9.16" style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.48044;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 634 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dykujenp3l608"
path="res://.godot/imported/eventsub-icon.svg-ce0c1dc80296ea912c84ec427215a2e3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/eventsub-icon.svg"
dest_files=["res://.godot/imported/eventsub-icon.svg-ce0c1dc80296ea912c84ec427215a2e3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="none" stroke="#e0e0e0" stroke-width="1.411" d="M6.04 1.654H1.36v12.692h13.28V10.24"/><path fill="#e0e0e0" d="M15.346.948v6.223l-2.14 2.14V4.643L8.734 9.116 7.178 7.56l4.473-4.473H6.983L9.123.948z"/></svg>

After

Width:  |  Height:  |  Size: 279 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bwgk2bv7wbbo7"
path="res://.godot/imported/ext-link.svg-e60b226c6ba08585f6582dc226fddebb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/ext-link.svg"
dest_files=["res://.godot/imported/ext-link.svg-e60b226c6ba08585f6582dc226fddebb.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,9 @@
[gd_resource type="GradientTexture1D" load_steps=2 format=3 uid="uid://g1dbcjksbotw"]
[sub_resource type="Gradient" id="Gradient_dsf6b"]
offsets = PackedFloat32Array(0)
colors = PackedColorArray(0.921569, 0.227451, 0.988235, 1)
[resource]
gradient = SubResource("Gradient_dsf6b")
width = 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,13 @@
[gd_resource type="ImageTexture" load_steps=2 format=3 uid="uid://1e6nrtqsuc6"]
[sub_resource type="Image" id="Image_mutbh"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 68, 224, 224, 224, 184, 224, 224, 224, 240, 224, 224, 224, 232, 224, 224, 224, 186, 227, 227, 227, 62, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 129, 224, 224, 224, 254, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 68, 224, 224, 224, 254, 224, 224, 224, 254, 224, 224, 224, 123, 224, 224, 224, 32, 224, 224, 224, 33, 225, 225, 225, 125, 224, 224, 224, 254, 224, 224, 224, 254, 226, 226, 226, 69, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 184, 224, 224, 224, 255, 224, 224, 224, 123, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 125, 224, 224, 224, 255, 225, 225, 225, 174, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 240, 224, 224, 224, 255, 231, 231, 231, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 226, 226, 226, 35, 224, 224, 224, 255, 224, 224, 224, 233, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 232, 224, 224, 224, 255, 224, 224, 224, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 228, 228, 37, 224, 224, 224, 255, 224, 224, 224, 228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 186, 224, 224, 224, 255, 224, 224, 224, 123, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 130, 224, 224, 224, 255, 224, 224, 224, 173, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 227, 227, 227, 62, 224, 224, 224, 255, 224, 224, 224, 254, 225, 225, 225, 126, 225, 225, 225, 34, 227, 227, 227, 36, 224, 224, 224, 131, 224, 224, 224, 255, 224, 224, 224, 255, 226, 226, 226, 77, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 122, 224, 224, 224, 254, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 226, 226, 226, 69, 225, 225, 225, 174, 224, 224, 224, 233, 224, 224, 224, 228, 224, 224, 224, 173, 226, 226, 226, 77, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 227, 225, 225, 225, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 225, 225, 225, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 16,
"mipmaps": false,
"width": 16
}
[resource]
image = SubResource("Image_mutbh")

View file

@ -0,0 +1,4 @@
[gd_resource type="LabelSettings" format=3 uid="uid://d12dapnv7b00n"]
[resource]
font_color = Color(0.400671, 0.976237, 1, 1)

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3.648 1.438h11.003v8.884H3.648z" style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none"/><path d="M3.857 9.927 7.408 5.02s2.28 3.284 2.28 3.185 1.88-1.74 1.88-1.74l2.786 3.482z" style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:.839927;stroke-linejoin:round;-inkscape-stroke:none"/><circle cx="10.679" cy="3.746" r=".807" style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:.890358;stroke-linejoin:round;-inkscape-stroke:none"/><path d="m1.145 10.558 2.658 4.157 2.858-4.157" style="fill:#73f280;fill-opacity:1;stroke:none;stroke-width:1.35663;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://drsj3w203jihf"
path="res://.godot/imported/media-loader-icon.svg-7569e70d457bf77b040db83e53346e05.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/media-loader-icon.svg"
dest_files=["res://.godot/imported/media-loader-icon.svg-7569e70d457bf77b040db83e53346e05.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View file

@ -0,0 +1,35 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://6nflfslr4a52"
path.s3tc="res://.godot/imported/no_profile.png-c0302c23dfe26865f13493d5d6d52fb6.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://addons/twitcher/assets/no_profile.png"
dest_files=["res://.godot/imported/no_profile.png-c0302c23dfe26865f13493d5d6d52fb6.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m11.932 2.744-1.089-.199-.983 1.416a4 4 0 0 0-.97.225l-1.635-.778-.845.713.474 1.693q-.266.417-.426.89l-1.623.768.016 1.104 1.62.707q.15.436.417.854l.021.032-.426 1.733.868.685 1.586-.816q.476.166.971.21l1.047 1.408 1.082-.233.385-1.771c.28-.174.541-.383.774-.626l1.72.088.51-.982-1.142-1.454q.051-.495-.015-.973l1.15-1.297-.453-1.01-1.845-.012a4 4 0 0 0-.736-.58Zm-.955 2.078a3.22 3.22 0 0 1 2.454 3.845c-.314 1.42-1.385 2.267-2.587 2.48-1.201.213-2.5-.216-3.281-1.441s-.624-2.583.075-3.583 1.919-1.615 3.339-1.301" style="fill:#e0e0e0;fill-opacity:1;stroke-width:.801266;stroke-linejoin:round"/><path d="M1.46 5.184h2.615" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><path d="M1.46 10.816h2.615" style="font-variation-settings:normal;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><path d="M.773 8h2.554" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#e0e0e0;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvvl6migokgdw"
path="res://.godot/imported/service-icon.svg-e43d86792dba5abf158c9a90e8467717.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/service-icon.svg"
dest_files=["res://.godot/imported/service-icon.svg-e43d86792dba5abf158c9a90e8467717.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Twitcher - Login</title>
<script>setTimeout(() => window.close(), 5000);</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #22c55e, #16a34a);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #133620;
}
.success-container {
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 15px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
max-width: 400px;
}
.success-icon {
font-size: 60px;
margin-bottom: 15px;
}
.success-message {
font-size: 24px;
font-weight: bold;
}
.success-description {
font-size: 18px;
margin-top: 10px;
text-align: justify;
}
.button {
display: inline-block;
cursor: pointer;
margin-top: 20px;
padding: 12px 25px;
background-color: white;
color: #15803d;
text-decoration: none;
font-weight: bold;
border-radius: 5px;
transition: background 0.3s ease, transform 0.2s ease;
}
.button:hover {
background-color: #d1fae5;
transform: scale(1.05);
}
</style>
</head>
<body>
<div class="success-container">
<div class="success-message">Login Success</div>
<div class="success-description">Page should automatically close when it doesn't happen close it manually.</div>
<a onclick="window.close()" class="button">Close Page</a>
</div>
</body>
</html>

View file

@ -0,0 +1,9 @@
[gd_resource type="LabelSettings" load_steps=2 format=3 uid="uid://bnsxy6gcm8q11"]
[sub_resource type="SystemFont" id="SystemFont_rtf3j"]
font_weight = 800
force_autohinter = true
[resource]
font = SubResource("SystemFont_rtf3j")
font_size = 18

View file

@ -0,0 +1,9 @@
[gd_resource type="GradientTexture1D" load_steps=2 format=3 uid="uid://bdhuy21ldt2vv"]
[sub_resource type="Gradient" id="Gradient_y3p12"]
offsets = PackedFloat32Array(1)
colors = PackedColorArray(1, 1, 1, 0)
[resource]
gradient = SubResource("Gradient_y3p12")
width = 1

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="none" stroke="#e0e0e0" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.5" d="M1.7 2h13v9.1H7.5l-2.9 2.6v-2.6H1.8z" style="stroke-width:1;stroke-dasharray:none"/><path d="M4.508 4.787h2.386v3.558H4.508z" style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/><path d="M9.994 4.787h2.386v3.558H9.994z" style="font-variation-settings:normal;vector-effect:none;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none"/></svg>

After

Width:  |  Height:  |  Size: 885 B

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ctljyyw6gikq0"
path="res://.godot/imported/twitcher-icon.svg-eae8d458f370f3edcefb2f6360cb1ecc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/assets/twitcher-icon.svg"
dest_files=["res://.godot/imported/twitcher-icon.svg-eae8d458f370f3edcefb2f6360cb1ecc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,4 @@
[gd_resource type="LabelSettings" format=3 uid="uid://cng881nsuud80"]
[resource]
font_color = Color(1, 0.870588, 0.4, 1)

View file

@ -0,0 +1,8 @@
[gd_resource type="Resource" script_class="TwitchOAuthScopes" load_steps=2 format=3 uid="uid://3dm6ts8hwlys"]
[ext_resource type="Script" uid="uid://b3n3et8mebjcc" path="res://addons/twitcher/auth/twitch_oauth_scopes.gd" id="1_bpjq8"]
[resource]
script = ExtResource("1_bpjq8")
used_scopes = Array[StringName]([&"user:read:chat", &"user:write:chat", &"user:bot"])
metadata/_custom_type_script = "uid://b3n3et8mebjcc"

View file

@ -0,0 +1,8 @@
[gd_resource type="Resource" script_class="TwitchOAuthScopes" load_steps=2 format=3 uid="uid://fcmfkstye4bq"]
[ext_resource type="Script" uid="uid://b3n3et8mebjcc" path="res://addons/twitcher/auth/twitch_oauth_scopes.gd" id="1_2b4sa"]
[resource]
script = ExtResource("1_2b4sa")
used_scopes = Array[StringName]([&"user:read:chat", &"user:write:chat", &"user:bot"])
metadata/_custom_type_script = "uid://b3n3et8mebjcc"

View file

@ -0,0 +1,135 @@
@icon("res://addons/twitcher/assets/auth-icon.svg")
@tool
extends Twitcher
## Delegate class for the oOuch Library.
class_name TwitchAuth
const HttpUtil = preload("res://addons/twitcher/lib/http/http_util.gd")
static var _log: TwitchLogger = TwitchLogger.new("TwitchAuth")
## The requested devicecode to show to the user for authorization
signal device_code_requested(device_code: OAuth.OAuthDeviceCodeResponse);
## Where and how to authorize.
@export var oauth_setting: OAuthSetting:
set(val):
oauth_setting = val
if auth != null: auth.oauth_setting = oauth_setting
if token_handler != null: token_handler.oauth_setting = oauth_setting
update_configuration_warnings()
## Shows the what to authorize page of twitch again. (for example you need to relogin with a different account aka bot account)
@export var force_verify: bool
## Where should the tokens be saved into
@export var token: OAuthToken:
set(val):
token = val
if token_handler != null: token_handler.token = token
update_configuration_warnings()
## Scopes for the token that should be requested
@export var scopes: OAuthScopes:
set(val):
scopes = val
if auth != null: auth.scopes = scopes
update_configuration_warnings()
## Takes care to authorize the user
@onready var auth: OAuth
## Takes care to fetch and refresh oauth tokens
@onready var token_handler: TwitchTokenHandler
var is_authenticated: bool:
get(): return auth.is_authenticated()
func _init() -> void:
child_entered_tree.connect(_on_enter_child)
# There could be better locations but this ensures that its there when an
# auth is needed.
var http_logger = TwitchLogger.new("Http")
HttpUtil.set_logger(http_logger.e, http_logger.i, http_logger.d)
func _on_enter_child(node: Node) -> void:
if node is OAuth: auth = node
if node is TwitchTokenHandler: token_handler = node
func _ready() -> void:
OAuth.set_logger(_log.e, _log.i, _log.d);
if oauth_setting == null: oauth_setting = create_default_oauth_setting()
_ensure_children()
func _ensure_children() -> void:
if token_handler == null:
token_handler = TwitchTokenHandler.new()
token_handler.name = "TokenHandler"
if auth == null:
auth = OAuth.new()
auth.name = "OAuth"
_sync_childs()
if not auth.is_inside_tree():
add_child(auth)
auth.owner = owner
if not token_handler.is_inside_tree():
add_child(token_handler)
token_handler.owner = owner
func _sync_childs() -> void:
token_handler.oauth_setting = oauth_setting
token_handler.token = token
auth.token_handler = token_handler
auth.oauth_setting = oauth_setting
auth.scopes = scopes
auth.force_verify = &"true" if force_verify else &"false"
func authorize() -> bool:
_sync_childs()
if await auth.login():
token_handler.process_mode = Node.PROCESS_MODE_INHERIT
return true
return false
func refresh_token() -> void:
auth.refresh_token()
static func create_default_oauth_setting() -> OAuthSetting:
var oauth_setting = OAuthSetting.new()
oauth_setting.authorization_flow = OAuth.AuthorizationFlow.AUTHORIZATION_CODE_FLOW
oauth_setting.device_authorization_url = "https://id.twitch.tv/oauth2/device"
oauth_setting.token_url = "https://id.twitch.tv/oauth2/token"
oauth_setting.authorization_url = "https://id.twitch.tv/oauth2/authorize"
oauth_setting.cache_file = "user://auth.conf"
oauth_setting.redirect_url = "http://localhost:7170"
return oauth_setting
## Checks if the correctly setup
func is_configured() -> bool:
return _get_configuration_warnings().is_empty()
func _get_configuration_warnings() -> PackedStringArray:
var result: PackedStringArray = []
if oauth_setting == null:
result.append("OAuthSetting missing")
else:
var oauth_setting_problems : PackedStringArray = oauth_setting.get_valididation_problems()
if not oauth_setting_problems.is_empty():
result.append("OAuthSetting is invalid")
result.append_array(oauth_setting_problems)
if scopes == null:
result.append("OAuthScopes is missing")
if token == null:
result.append("OAuthToken is missing")
return result

View file

@ -0,0 +1 @@
uid://iv0mgv0lu8b0

View file

@ -0,0 +1,6 @@
@tool
@icon("res://addons/twitcher/lib/oOuch/scope-icon.svg")
extends OAuthScopes
## Technically the same like OAuthScopes just with a custom inspector for Twitch scopes
class_name TwitchOAuthScopes

View file

@ -0,0 +1 @@
uid://b3n3et8mebjcc

View file

@ -0,0 +1,195 @@
@tool
extends Resource
class_name TwitchScope
class Definition extends Object:
var value: StringName
var description: String
var categroy: String
func _init(val: StringName, desc: String, cat: String = "") -> void:
value = val
description = desc
categroy = cat
func get_category() -> String:
if categroy: return categroy
return value.substr(0, value.find(":"))
static var ANALYTICS_READ_EXTENSIONS = Definition.new(&"analytics:read:extensions", "View analytics data for the Twitch Extensions owned by the authenticated account.")
static var ANALYTICS_READ_GAMES = Definition.new(&"analytics:read:games", "View analytics data for the games owned by the authenticated account.")
static var BITS_READ = Definition.new(&"bits:read", "View Bits information for a channel.")
static var CHANNEL_BOT = Definition.new(&"channel:bot", "Joins your channels chatroom as a bot user, and perform chat-related actions as that user.")
static var CHANNEL_MANAGE_ADS = Definition.new(&"channel:manage:ads", "Manage ads schedule on a channel.")
static var CHANNEL_READ_ADS = Definition.new(&"channel:read:ads", "Read the ads schedule and details on your channel.")
static var CHANNEL_MANAGE_BROADCAST = Definition.new(&"channel:manage:broadcast", "Manage a channels broadcast configuration, including updating channel configuration and managing stream markers and stream tags.")
static var CHANNEL_READ_CHARITY = Definition.new(&"channel:read:charity", "Read charity campaign details and user donations on your channel.")
static var CHANNEL_EDIT_COMMERCIAL = Definition.new(&"channel:edit:commercial", "Run commercials on a channel.")
static var CHANNEL_READ_EDITORS = Definition.new(&"channel:read:editors", "View a list of users with the editor role for a channel.")
static var CHANNEL_MANAGE_EXTENSIONS = Definition.new(&"channel:manage:extensions", "Manage a channels Extension configuration, including activating Extensions.")
static var CHANNEL_READ_GOALS = Definition.new(&"channel:read:goals", "View Creator Goals for a channel.")
static var CHANNEL_READ_GUEST_STAR = Definition.new(&"channel:read:guest_star", "Read Guest Star details for your channel.")
static var CHANNEL_MANAGE_GUEST_STAR = Definition.new(&"channel:manage:guest_star", "Manage Guest Star for your channel.")
static var CHANNEL_READ_HYPE_TRAIN = Definition.new(&"channel:read:hype_train", "View Hype Train information for a channel.")
static var CHANNEL_MANAGE_MODERATORS = Definition.new(&"channel:manage:moderators", "Add or remove the moderator role from users in your channel.")
static var CHANNEL_READ_POLLS = Definition.new(&"channel:read:polls", "View a channels polls.")
static var CHANNEL_MANAGE_POLLS = Definition.new(&"channel:manage:polls", "Manage a channels polls.")
static var CHANNEL_READ_PREDICTIONS = Definition.new(&"channel:read:predictions", "View a channels Channel Points Predictions.")
static var CHANNEL_MANAGE_PREDICTIONS = Definition.new(&"channel:manage:predictions", "Manage of channels Channel Points Predictions")
static var CHANNEL_MANAGE_RAIDS = Definition.new(&"channel:manage:raids", "Manage a channel raiding another channel.")
static var CHANNEL_READ_REDEMPTIONS = Definition.new(&"channel:read:redemptions", "View Channel Points custom rewards and their redemptions on a channel.")
static var CHANNEL_MANAGE_REDEMPTIONS = Definition.new(&"channel:manage:redemptions", "Manage Channel Points custom rewards and their redemptions on a channel.")
static var CHANNEL_MANAGE_SCHEDULE = Definition.new(&"channel:manage:schedule", "Manage a channels stream schedule.")
static var CHANNEL_READ_STREAM_KEY = Definition.new(&"channel:read:stream_key", "View an authorized users stream key.")
static var CHANNEL_READ_SUBSCRIPTIONS = Definition.new(&"channel:read:subscriptions", "View a list of all subscribers to a channel and check if a user is subscribed to a channel.")
static var CHANNEL_MANAGE_VIDEOS = Definition.new(&"channel:manage:videos", "Manage a channels videos, including deleting videos.")
static var CHANNEL_READ_VIPS = Definition.new(&"channel:read:vips", "Read the list of VIPs in your channel.")
static var CHANNEL_MANAGE_VIPS = Definition.new(&"channel:manage:vips", "Add or remove the VIP role from users in your channel.")
static var CLIPS_EDIT = Definition.new(&"clips:edit", "Manage Clips for a channel.")
static var MODERATION_READ = Definition.new(&"moderation:read", "View a channels moderation data including Moderators, Bans, Timeouts, and Automod settings.")
static var MODERATOR_MANAGE_ANNOUNCEMENTS = Definition.new(&"moderator:manage:announcements", "Send announcements in channels where you have the moderator role.")
static var MODERATOR_MANAGE_AUTOMOD = Definition.new(&"moderator:manage:automod", "Manage messages held for review by AutoMod in channels where you are a moderator.")
static var MODERATOR_READ_AUTOMOD_SETTINGS = Definition.new(&"moderator:read:automod_settings", "View a broadcasters AutoMod settings.")
static var MODERATOR_MANAGE_AUTOMOD_SETTINGS = Definition.new(&"moderator:manage:automod_settings", "Manage a broadcasters AutoMod settings.")
static var MODERATOR_READ_BANNED_USERS = Definition.new(&"moderator:read:banned_users", "Read the list of bans or unbans in channels where you have the moderator role.")
static var MODERATOR_MANAGE_BANNED_USERS = Definition.new(&"moderator:manage:banned_users", "Ban and unban users.")
static var MODERATOR_READ_BLOCKED_TERMS = Definition.new(&"moderator:read:blocked_terms", "View a broadcasters list of blocked terms.")
static var MODERATOR_READ_CHAT_MESSAGES = Definition.new(&"moderator:read:chat_messages", "Read deleted chat messages in channels where you have the moderator role.")
static var MODERATOR_MANAGE_BLOCKED_TERMS = Definition.new(&"moderator:manage:blocked_terms", "Manage a broadcasters list of blocked terms.")
static var MODERATOR_MANAGE_CHAT_MESSAGES = Definition.new(&"moderator:manage:chat_messages", "Delete chat messages in channels where you have the moderator role")
static var MODERATOR_READ_CHAT_SETTINGS = Definition.new(&"moderator:read:chat_settings", "View a broadcasters chat room settings.")
static var MODERATOR_MANAGE_CHAT_SETTINGS = Definition.new(&"moderator:manage:chat_settings", "Manage a broadcasters chat room settings.")
static var MODERATOR_READ_CHATTERS = Definition.new(&"moderator:read:chatters", "View the chatters in a broadcasters chat room.")
static var MODERATOR_READ_FOLLOWERS = Definition.new(&"moderator:read:followers", "Read the followers of a broadcaster.")
static var MODERATOR_READ_GUEST_STAR = Definition.new(&"moderator:read:guest_star", "Read Guest Star details for channels where you are a Guest Star moderator.")
static var MODERATOR_MANAGE_GUEST_STAR = Definition.new(&"moderator:manage:guest_star", "Manage Guest Star for channels where you are a Guest Star moderator.")
static var MODERATOR_READ_MODERATORS = Definition.new(&"moderator:read:moderators", "Read the list of moderators in channels where you have the moderator role.")
static var MODERATOR_READ_SHIELD_MODE = Definition.new(&"moderator:read:shield_mode", "View a broadcasters Shield Mode status.")
static var MODERATOR_MANAGE_SHIELD_MODE = Definition.new(&"moderator:manage:shield_mode", "Manage a broadcasters Shield Mode status.")
static var MODERATOR_READ_SHOUTOUTS = Definition.new(&"moderator:read:shoutouts", "View a broadcasters shoutouts.")
static var MODERATOR_MANAGE_SHOUTOUTS = Definition.new(&"moderator:manage:shoutouts", "Manage a broadcasters shoutouts.")
static var MODERATOR_READ_SUSPICIOUS_USERS = Definition.new(&"moderator:read:suspicious_users", "Read chat messages from suspicious users and see users flagged as suspicious in channels where you have the moderator role.")
static var MODERATOR_READ_UNBAN_REQUESTS = Definition.new(&"moderator:read:unban_requests", "View a broadcasters unban requests.")
static var MODERATOR_MANAGE_UNBAN_REQUESTS = Definition.new(&"moderator:manage:unban_requests", "Manage a broadcasters unban requests.")
static var MODERATOR_READ_VIPS = Definition.new(&"moderator:read:vips", "Read the list of VIPs in channels where you have the moderator role.")
static var MODERATOR_READ_WARNINGS = Definition.new(&"moderator:read:warnings", "Read warnings in channels where you have the moderator role.")
static var MODERATOR_MANAGE_WARNINGS = Definition.new(&"moderator:manage:warnings", "Warn users in channels where you have the moderator role.")
static var USER_BOT = Definition.new(&"user:bot", "Join a specified chat channel as your user and appear as a bot, and perform chat-related actions as your user.")
static var USER_EDIT = Definition.new(&"user:edit", "Manage a user object.")
static var USER_EDIT_BROADCAST = Definition.new(&"user:edit:broadcast", "View and edit a users broadcasting configuration, including Extension configurations.")
static var USER_READ_BLOCKED_USERS = Definition.new(&"user:read:blocked_users", "View the block list of a user.")
static var USER_MANAGE_BLOCKED_USERS = Definition.new(&"user:manage:blocked_users", "Manage the block list of a user.")
static var USER_READ_BROADCAST = Definition.new(&"user:read:broadcast", "View a users broadcasting configuration, including Extension configurations.")
static var USER_READ_CHAT = Definition.new(&"user:read:chat", "Receive chatroom messages and informational notifications relating to a channels chatroom.")
static var USER_MANAGE_CHAT_COLOR = Definition.new(&"user:manage:chat_color", "Update the color used for the users name in chat.")
static var USER_READ_EMAIL = Definition.new(&"user:read:email", "View a users email address.")
static var USER_READ_EMOTES = Definition.new(&"user:read:emotes", "View emotes available to a user")
static var USER_READ_FOLLOWS = Definition.new(&"user:read:follows", "View the list of channels a user follows.")
static var USER_READ_MODERATED_CHANNELS = Definition.new(&"user:read:moderated_channels", "Read the list of channels you have moderator privileges in.")
static var USER_READ_SUBSCRIPTIONS = Definition.new(&"user:read:subscriptions", "View if an authorized user is subscribed to specific channels.")
static var USER_READ_WHISPERS = Definition.new(&"user:read:whispers", "Receive whispers sent to your user.")
static var USER_MANAGE_WHISPERS = Definition.new(&"user:manage:whispers", "Receive whispers sent to your user, and send whispers on your users behalf.")
static var USER_WRITE_CHAT = Definition.new(&"user:write:chat", "Send chat messages to a chatroom.")
static var CHAT_READ = Definition.new(&"chat:edit", "Send chat messages to a chatroom using an IRC connection.", "IRC")
static var CHAT_EDIT = Definition.new(&"chat:read", "View chat messages sent in a chatroom using an IRC connection.", "IRC")
## Key: Scope Name as String | Value: Definition
static var SCOPE_MAP: Dictionary = {
"analytics:read:extensions" = ANALYTICS_READ_EXTENSIONS,
"analytics:read:games" = ANALYTICS_READ_GAMES,
"bits:read" = BITS_READ,
"channel:bot" = CHANNEL_BOT,
"channel:manage:ads" = CHANNEL_MANAGE_ADS,
"channel:read:ads" = CHANNEL_READ_ADS,
"channel:manage:broadcast" = CHANNEL_MANAGE_BROADCAST,
"channel:read:charity" = CHANNEL_READ_CHARITY,
"channel:edit:commercial" = CHANNEL_EDIT_COMMERCIAL,
"channel:read:editors" = CHANNEL_READ_EDITORS,
"channel:manage:extensions" = CHANNEL_MANAGE_EXTENSIONS,
"channel:read:goals" = CHANNEL_READ_GOALS,
"channel:read:guest_star" = CHANNEL_READ_GUEST_STAR,
"channel:manage:guest_star" = CHANNEL_MANAGE_GUEST_STAR,
"channel:read:hype_train" = CHANNEL_READ_HYPE_TRAIN,
"channel:manage:moderators" = CHANNEL_MANAGE_MODERATORS,
"channel:read:polls" = CHANNEL_READ_POLLS,
"channel:manage:polls" = CHANNEL_MANAGE_POLLS,
"channel:read:predictions" = CHANNEL_READ_PREDICTIONS,
"channel:manage:predictions" = CHANNEL_MANAGE_PREDICTIONS,
"channel:manage:raids" = CHANNEL_MANAGE_RAIDS,
"channel:read:redemptions" = CHANNEL_READ_REDEMPTIONS,
"channel:manage:redemptions" = CHANNEL_MANAGE_REDEMPTIONS,
"channel:manage:schedule" = CHANNEL_MANAGE_SCHEDULE,
"channel:read:stream_key" = CHANNEL_READ_STREAM_KEY,
"channel:read:subscriptions" = CHANNEL_READ_SUBSCRIPTIONS,
"channel:manage:videos" = CHANNEL_MANAGE_VIDEOS,
"channel:read:vips" = CHANNEL_READ_VIPS,
"channel:manage:vips" = CHANNEL_MANAGE_VIPS,
"clips:edit" = CLIPS_EDIT,
"moderation:read" = MODERATION_READ,
"moderator:manage:announcements" = MODERATOR_MANAGE_ANNOUNCEMENTS,
"moderator:manage:automod" = MODERATOR_MANAGE_AUTOMOD,
"moderator:read:automod_settings" = MODERATOR_READ_AUTOMOD_SETTINGS,
"moderator:manage:automod_settings" = MODERATOR_MANAGE_AUTOMOD_SETTINGS,
"moderator:read:banned_users" = MODERATOR_READ_BANNED_USERS,
"moderator:manage:banned_users" = MODERATOR_MANAGE_BANNED_USERS,
"moderator:read:blocked_terms" = MODERATOR_READ_BLOCKED_TERMS,
"moderator:read:chat_messages" = MODERATOR_READ_CHAT_MESSAGES,
"moderator:manage:blocked_terms" = MODERATOR_MANAGE_BLOCKED_TERMS,
"moderator:manage:chat_messages" = MODERATOR_MANAGE_CHAT_MESSAGES,
"moderator:read:chat_settings" = MODERATOR_READ_CHAT_SETTINGS,
"moderator:manage:chat_settings" = MODERATOR_MANAGE_CHAT_SETTINGS,
"moderator:read:chatters" = MODERATOR_READ_CHATTERS,
"moderator:read:followers" = MODERATOR_READ_FOLLOWERS,
"moderator:read:guest_star" = MODERATOR_READ_GUEST_STAR,
"moderator:manage:guest_star" = MODERATOR_MANAGE_GUEST_STAR,
"moderator:read:moderators" = MODERATOR_READ_MODERATORS,
"moderator:read:shield_mode" = MODERATOR_READ_SHIELD_MODE,
"moderator:manage:shield_mode" = MODERATOR_MANAGE_SHIELD_MODE,
"moderator:read:shoutouts" = MODERATOR_READ_SHOUTOUTS,
"moderator:manage:shoutouts" = MODERATOR_MANAGE_SHOUTOUTS,
"moderator:read:suspicious_users" = MODERATOR_READ_SUSPICIOUS_USERS,
"moderator:read:unban_requests" = MODERATOR_READ_UNBAN_REQUESTS,
"moderator:manage:unban_requests" = MODERATOR_MANAGE_UNBAN_REQUESTS,
"moderator:read:vips" = MODERATOR_READ_VIPS,
"moderator:read:warnings" = MODERATOR_READ_WARNINGS,
"moderator:manage:warnings" = MODERATOR_MANAGE_WARNINGS,
"user:bot" = USER_BOT,
"user:edit" = USER_EDIT,
"user:edit:broadcast" = USER_EDIT_BROADCAST,
"user:read:blocked_users" = USER_READ_BLOCKED_USERS,
"user:manage:blocked_users" = USER_MANAGE_BLOCKED_USERS,
"user:read:broadcast" = USER_READ_BROADCAST,
"user:read:chat" = USER_READ_CHAT,
"user:manage:chat_color" = USER_MANAGE_CHAT_COLOR,
"user:read:email" = USER_READ_EMAIL,
"user:read:emotes" = USER_READ_EMOTES,
"user:read:follows" = USER_READ_FOLLOWS,
"user:read:moderated_channels" = USER_READ_MODERATED_CHANNELS,
"user:read:subscriptions" = USER_READ_SUBSCRIPTIONS,
"user:read:whispers" = USER_READ_WHISPERS,
"user:manage:whispers" = USER_MANAGE_WHISPERS,
"user:write:chat" = USER_WRITE_CHAT,
"chat:read" = CHAT_READ,
"chat:edit" = CHAT_EDIT,
}
## Key: Category as String, value as Array[Definition]
static func get_grouped_scopes() -> Dictionary:
var result = {}
for scope: Definition in get_all_scopes():
var category_name = scope.get_category()
var category = result.get_or_add(category_name, [])
category.append(scope)
return result
static func get_all_scopes() -> Array[Definition]:
var scopes: Array[Definition] = []
for scope in SCOPE_MAP.values():
scopes.append(scope)
return scopes

View file

@ -0,0 +1 @@
uid://bjgpdxggorn0l

View file

@ -0,0 +1,50 @@
@icon("res://addons/twitcher/assets/auth-icon.svg")
@tool
extends OAuthTokenHandler
class_name TwitchTokenHandler
## Time between checking the validation of the tokens
var _last_validation_check: int = 0
func _ready() -> void:
super._ready()
# We don't need to check right after the start
_last_validation_check = Time.get_ticks_msec() + 60 * 60 * 1000;
func _check_token_refresh() -> void:
super._check_token_refresh()
if _last_validation_check < Time.get_ticks_msec():
_validate_token()
## Calles the validation endpoint of Twtich to make sure
func _validate_token() -> void:
_last_validation_check = Time.get_ticks_msec() + 60 * 60 * 1000;
var validation_request = _http_client.request("https://id.twitch.tv/oauth2/validate", HTTPClient.METHOD_GET, {
"Authorization": "OAuth %s" % await token.get_access_token()
}, "")
var response = await _http_client.wait_for_request(validation_request)
if response.response_code != 200:
refresh_tokens()
return
var response_string: String = response.response_data.get_string_from_utf8();
var response_data = JSON.parse_string(response_string);
if response_data["expires_in"] <= 0:
refresh_tokens()
return
func revoke_token() -> void:
var request = _http_client.request("https://id.twitch.tv/oauth2/revoke", HTTPClient.METHOD_POST,
{ "Content-Type": "application/x-www-form-urlencoded" },
"client_id=%s&token=%s" % [oauth_setting.client_id, await token.get_access_token()])
var response: BufferedHTTPClient.ResponseData = await _http_client.wait_for_request(request)
if response.error:
var response_message = response.response_data.get_string_from_utf8()
logError("Couldn't revoke Token cause of: %s" % response_message)
token.remove_tokens()

View file

@ -0,0 +1 @@
uid://blnbogtrshw4r

View file

@ -0,0 +1,14 @@
extends RefCounted
class_name TwitchAnnouncementColor
static var BLUE: TwitchAnnouncementColor = TwitchAnnouncementColor.new("blue")
static var GREEN: TwitchAnnouncementColor = TwitchAnnouncementColor.new("green")
static var ORANGE: TwitchAnnouncementColor = TwitchAnnouncementColor.new("orange")
static var PURPLE: TwitchAnnouncementColor = TwitchAnnouncementColor.new("purple")
static var PRIMARY: TwitchAnnouncementColor = TwitchAnnouncementColor.new("primary")
var value;
func _init(color: String) -> void:
value = color;

View file

@ -0,0 +1 @@
uid://doop8abj8sed6

View file

@ -0,0 +1,116 @@
@icon("../assets/chat-icon.svg")
@tool
extends Twitcher
## Grants access to read and write to a chat
class_name TwitchChat
static var _log: TwitchLogger = TwitchLogger.new("TwitchChat")
static var instance: TwitchChat
@export var broadcaster_user: TwitchUser:
set(val):
broadcaster_user = val
update_configuration_warnings()
## Can be null. Then the owner of the access token will be used to send message aka the current user.
@export var sender_user: TwitchUser
@export var media_loader: TwitchMediaLoader
@export var eventsub: TwitchEventsub:
set(val):
eventsub = val
update_configuration_warnings()
@export var api: TwitchAPI:
set(val):
api = val
update_configuration_warnings()
## Should it subscribe on ready
@export var subscribe_on_ready: bool = true
## Triggered when a chat message got received
signal message_received(message: TwitchChatMessage)
func _ready() -> void:
_log.d("is ready")
if media_loader == null: media_loader = TwitchMediaLoader.instance
if api == null: api = TwitchAPI.instance
if eventsub == null: eventsub = TwitchEventsub.instance
eventsub.event.connect(_on_event_received)
if not Engine.is_editor_hint() && subscribe_on_ready:
subscribe()
func _enter_tree() -> void:
if instance == null: instance = self
func _exit_tree() -> void:
if instance == self: instance = null
## Subscribe to eventsub and preload data if not happend yet
func subscribe() -> void:
if broadcaster_user == null:
printerr("BroadcasterUser is not set. Can't subscribe to chat.")
return
if is_instance_valid(media_loader):
media_loader.preload_badges(broadcaster_user.id)
media_loader.preload_emotes(broadcaster_user.id)
for subscription: TwitchEventsubConfig in eventsub.get_subscriptions():
if subscription.type == TwitchEventsubDefinition.Type.CHANNEL_CHAT_MESSAGE and \
subscription.condition.broadcaster_user_id == broadcaster_user.id:
# it is already subscribed
return
if sender_user == null:
var current_user: TwitchGetUsers.Response = await api.get_users(null)
sender_user = current_user.data[0]
var config: TwitchEventsubConfig = TwitchEventsubConfig.new()
config.type = TwitchEventsubDefinition.Type.CHANNEL_CHAT_MESSAGE
config.condition = {
"broadcaster_user_id": broadcaster_user.id,
"user_id": sender_user.id
}
eventsub.subscribe(config)
_log.i("Listen to Chat of %s (%s)" % [broadcaster_user.display_name, broadcaster_user.id])
func _on_event_received(type: StringName, data: Dictionary) -> void:
if type != TwitchEventsubDefinition.CHANNEL_CHAT_MESSAGE.value: return
var message: TwitchChatMessage = TwitchChatMessage.from_json(data)
if message.broadcaster_user_id == broadcaster_user.id:
message_received.emit(message)
func send_message(message: String, reply_parent_message_id: String = "") -> Array[TwitchSendChatMessage.ResponseData]:
var message_body: TwitchSendChatMessage.Body = TwitchSendChatMessage.Body.new()
message_body.broadcaster_id = broadcaster_user.id
message_body.sender_id = sender_user.id
message_body.message = message
if reply_parent_message_id:
message_body.reply_parent_message_id = reply_parent_message_id
var response: TwitchSendChatMessage.Response = await api.send_chat_message(message_body)
if _log.enabled:
for message_data: TwitchSendChatMessage.ResponseData in response.data:
if not message_data.is_sent:
_log.w(message_data.drop_reason)
return response.data
func _get_configuration_warnings() -> PackedStringArray:
var result: PackedStringArray = []
if eventsub == null:
result.append("TwitchEventsub not assigned")
if api == null:
result.append("TwitchAPI not assigned")
if broadcaster_user == null:
result.append("Target broadcaster not specified")
return result

View file

@ -0,0 +1 @@
uid://dcq1bvfrqimqq

View file

@ -0,0 +1,367 @@
extends RefCounted
class_name TwitchChatMessage
enum FragmentType {
text = 0,
cheermote = 1,
emote = 2,
mention = 3
}
const FRAGMENT_TYPES = ["text", "cheermote", "emote", "mention"]
enum EmoteFormat {
animated = 0,
_static = 1
}
const EMOTE_FORMATES = ["animated", "static"]
enum MessageType {
## Normal chat message
text = 0,
## The default reward where the message is highlighted
channel_points_highlighted = 1,
## Channel points were used to send this message in sub-only mode.
channel_points_sub_only = 2,
## when a new user is typing for the first time
user_intro = 3,
## When the power up message effect was used on this message
power_ups_message_effect = 4,
## When a gigantified emote was posted
power_ups_gigantified_emote = 5
}
const MESSAGE_TYPES = ["text", "channel_points_highlighted", "channel_points_sub_only", "user_intro", "power_ups_message_effect", "power_ups_gigantified_emote"]
class Message extends RefCounted:
## The chat message in plain text.
var text: String
## Ordered list of chat message fragments.
var fragments: Array[Fragment] = []
static func from_json(d: Dictionary) -> Message:
var result = Message.new()
if d.has("text") and d["text"] != null:
result.text = d["text"]
if d.has("fragments") and d["fragments"] != null:
for value in d["fragments"]:
result.fragments.append(Fragment.from_json(value))
return result
class Fragment extends RefCounted:
## The type of message fragment. See "TwitchChatMessage.FRAGMENT_TYPE_*"
var type: MessageType
## Message text in fragment.
var text: String
## Optional. Metadata pertaining to the cheermote.
var cheermote: Cheermote
## Optional. Metadata pertaining to the emote.
var emote: Emote
## Optional. Metadata pertaining to the mention.
var mention: Mention
static func from_json(d: Dictionary) -> Fragment:
var result = Fragment.new()
if d.has("type") and d["type"] != null:
result.type = FragmentType[d["type"]]
if d.has("text") and d["text"] != null:
result.text = d["text"]
if d.has("cheermote") and d["cheermote"] != null:
result.cheermote = Cheermote.from_json(d["cheermote"])
if d.has("emote") and d["emote"] != null:
result.emote = Emote.from_json(d["emote"])
if d.has("mention") and d["mention"] != null:
result.mention = Mention.from_json(d["mention"])
return result
class Mention extends RefCounted:
## The user ID of the mentioned user.
var user_id: String
## The user name of the mentioned user.
var user_name: String
## The user login of the mentioned user.
var user_login: String
static func from_json(d: Dictionary) -> Mention:
var result = Mention.new()
if d.has("user_id") and d["user_id"] != null:
result.user_id = d["user_id"]
if d.has("user_name") and d["user_name"] != null:
result.user_name = d["user_name"]
if d.has("user_login") and d["user_login"] != null:
result.user_login = d["user_login"]
return result
class Cheermote extends RefCounted:
## The name portion of the Cheermote string that you use in chat to cheer Bits. The full Cheermote string is the concatenation of {prefix} + {number of Bits}. For example, if the prefix is “Cheer” and you want to cheer 100 Bits, the full Cheermote string is Cheer100. When the Cheermote string is entered in chat, Twitch converts it to the image associated with the Bits tier that was cheered.
var prefix: String
## The amount of bits cheered.
var bits: int
## The tier level of the cheermote.
var tier: int
func get_sprite_frames(_media_loader: TwitchMediaLoader, cheermote_definition: TwitchCheermoteDefinition) -> SpriteFrames:
var cheer_results = await _media_loader.get_cheer_tier(prefix, "%s" % tier, cheermote_definition.theme, cheermote_definition.type, cheermote_definition.scale)
return cheer_results.spriteframes
static func from_json(d: Dictionary) -> Cheermote:
var result = Cheermote.new()
if d.has("prefix") and d["prefix"] != null:
result.prefix = d["prefix"]
if d.has("bits") and d["bits"] != null:
result.bits = d["bits"]
if d.has("tier") and d["tier"] != null:
result.tier = d["tier"]
return result
class Emote extends RefCounted:
## An ID that uniquely identifies this emote.
var id: String
## An ID that identifies the emote set that the emote belongs to.
var emote_set_id: String
## The ID of the broadcaster who owns the emote.
var owner_id: String
## The formats that the emote is available in. For example, if the emote is available only as a static PNG, the array contains only static. But if the emote is available as a static PNG and an animated GIF, the array contains static and animated. See: "TwitchChatMessage.EMOTE_TYPE_*"
var format: Array[EmoteFormat] = []
## Resolves the spriteframes from this emote. Check `format` for possible formats.
## Format: Defaults to animated when not available it uses static
## Scale: 1, 2, 3
func get_sprite_frames(_media_loader: TwitchMediaLoader, format: String = "", scale: int = 1, dark: bool = true) -> SpriteFrames:
var definition: TwitchEmoteDefinition = TwitchEmoteDefinition.new(id)
if dark: definition.theme_dark()
else: definition.theme_light()
match scale:
1: definition.scale_1()
2: definition.scale_2()
3: definition.scale_3()
_: definition.scale_1()
var emotes = await _media_loader.get_emotes_by_definition([definition])
return emotes[definition]
static func from_json(d: Dictionary) -> Emote:
var result = Emote.new()
if d.has("id") and d["id"] != null:
result.id = d["id"]
if d.has("emote_set_id") and d["emote_set_id"] != null:
result.emote_set_id = d["emote_set_id"]
if d.has("owner_id") and d["owner_id"] != null:
result.owner_id = d["owner_id"]
if d.has("format") and d["format"] != null:
for format in d["format"]:
if format == "static":
result.format.append(EmoteFormat._static)
elif format == "animated":
result.format.append(EmoteFormat.animated)
return result
class Badge extends RefCounted:
## An ID that identifies this set of chat badges. For example, Bits or Subscriber.
var set_id: String
## An ID that identifies this version of the badge. The ID can be any value. For example, for Bits, the ID is the Bits tier level, but for World of Warcraft, it could be Alliance or Horde.
var id: String
## Contains metadata related to the chat badges in the badges tag. Currently, this tag contains metadata only for subscriber badges, to indicate the number of months the user has been a subscriber.
var info: String
static func from_json(d: Dictionary) -> Badge:
var result = Badge.new()
if d.has("set_id") and d["set_id"] != null:
result.set_id = d["set_id"]
if d.has("id") and d["id"] != null:
result.id = d["id"]
if d.has("info") and d["info"] != null:
result.info = d["info"]
return result
class Cheer extends RefCounted:
## The amount of Bits the user cheered.
var bits: int
static func from_json(d: Dictionary) -> Cheer:
var result = Cheer.new()
if d.has("bits") and d["bits"] != null:
result.bits = d["bits"]
return result
class Reply extends RefCounted:
## An ID that uniquely identifies the parent message that this message is replying to.
var parent_message_id: String
## The message body of the parent message.
var parent_message_body: String
## User ID of the sender of the parent message.
var parent_user_id: String
## User name of the sender of the parent message.
var parent_user_name: String
## User login of the sender of the parent message.
var parent_user_login: String
## An ID that identifies the parent message of the reply thread.
var thread_message_id: String
## User ID of the sender of the threads parent message.
var thread_user_id: String
## User name of the sender of the threads parent message.
var thread_user_name: String
## User login of the sender of the threads parent message.
var thread_user_login: String
static func from_json(d: Dictionary) -> Reply:
var result = Reply.new()
if d.has("parent_message_id") and d["parent_message_id"] != null:
result.parent_message_id = d["parent_message_id"]
if d.has("parent_message_body") and d["parent_message_body"] != null:
result.parent_message_body = d["parent_message_body"]
if d.has("parent_user_id") and d["parent_user_id"] != null:
result.parent_user_id = d["parent_user_id"]
if d.has("parent_user_name") and d["parent_user_name"] != null:
result.parent_user_name = d["parent_user_name"]
if d.has("parent_user_login") and d["parent_user_login"] != null:
result.parent_user_login = d["parent_user_login"]
if d.has("thread_message_id") and d["thread_message_id"] != null:
result.thread_message_id = d["thread_message_id"]
if d.has("thread_user_id") and d["thread_user_id"] != null:
result.thread_user_id = d["thread_user_id"]
if d.has("thread_user_name") and d["thread_user_name"] != null:
result.thread_user_name = d["thread_user_name"]
if d.has("thread_user_login") and d["thread_user_login"] != null:
result.thread_user_login = d["thread_user_login"]
return result
## The broadcaster user ID.
var broadcaster_user_id: String
## The broadcaster display name.
var broadcaster_user_name: String
## The broadcaster login.
var broadcaster_user_login: String
## The user ID of the user that sent the message.
var chatter_user_id: String
## The user name of the user that sent the message.
var chatter_user_name: String
## The user login of the user that sent the message.
var chatter_user_login: String
## A UUID that identifies the message.
var message_id: String
## The structured chat message.
var message: Message
## The type of message.
var message_type: MessageType
## List of chat badges.
var badges: Array[Badge] = []
## Optional. Metadata if this message is a cheer.
var cheer: Cheer
## The color of the users name in the chat room. This is a hexadecimal RGB color code in the form, #<RGB>;. This tag may be empty if it is never set.
var color: String
## Optional. Metadata if this message is a reply.
var reply: Reply
## Optional. The ID of a channel points custom reward that was redeemed.
var channel_points_custom_reward_id: String
## Optional. The broadcaster user ID of the channel the message was sent from. Is null when the message happens in the same channel as the broadcaster. Is not null when in a shared chat session, and the action happens in the channel of a participant other than the broadcaster.
var source_broadcaster_user_id: String
## Optional. The user name of the broadcaster of the channel the message was sent from. Is null when the message happens in the same channel as the broadcaster. Is not null when in a shared chat session, and the action happens in the channel of a participant other than the broadcaster.
var source_broadcaster_user_name: String
## Optional. The login of the broadcaster of the channel the message was sent from. Is null when the message happens in the same channel as the broadcaster. Is not null when in a shared chat session, and the action happens in the channel of a participant other than the broadcaster.
var source_broadcaster_user_login: String
## Optional. The UUID that identifies the source message from the channel the message was sent from. Is null when the message happens in the same channel as the broadcaster. Is not null when in a shared chat session, and the action happens in the channel of a participant other than the broadcaster.
var source_message_id: String
## Optional. The list of chat badges for the chatter in the channel the message was sent from. Is null when the message happens in the same channel as the broadcaster. Is not null when in a shared chat session, and the action happens in the channel of a participant other than the broadcaster.
var source_badges: Array[Badge] = []
## Loads a chat message from Json decoded dictionary. TwitchService is optional in case images and badges should be load from the message.
static func from_json(d: Dictionary) -> TwitchChatMessage:
var result = TwitchChatMessage.new()
if d.has("broadcaster_user_id") and d["broadcaster_user_id"] != null:
result.broadcaster_user_id = d["broadcaster_user_id"]
if d.has("broadcaster_user_name") and d["broadcaster_user_name"] != null:
result.broadcaster_user_name = d["broadcaster_user_name"]
if d.has("broadcaster_user_login") and d["broadcaster_user_login"] != null:
result.broadcaster_user_login = d["broadcaster_user_login"]
if d.has("chatter_user_id") and d["chatter_user_id"] != null:
result.chatter_user_id = d["chatter_user_id"]
if d.has("chatter_user_name") and d["chatter_user_name"] != null:
result.chatter_user_name = d["chatter_user_name"]
if d.has("chatter_user_login") and d["chatter_user_login"] != null:
result.chatter_user_login = d["chatter_user_login"]
if d.has("message_id") and d["message_id"] != null:
result.message_id = d["message_id"]
if d.has("message") and d["message"] != null:
result.message = Message.from_json(d["message"])
if d.has("message_type") and d["message_type"] != null:
result.message_type = MessageType[d["message_type"]]
if d.has("badges") and d["badges"] != null:
for value in d["badges"]:
result.badges.append(Badge.from_json(value))
if d.has("cheer") and d["cheer"] != null:
result.cheer = Cheer.from_json(d["cheer"])
if d.has("color") and d["color"] != null:
result.color = d["color"]
if d.has("reply") and d["reply"] != null:
result.reply = Reply.from_json(d["reply"])
if d.has("channel_points_custom_reward_id") and d["channel_points_custom_reward_id"] != null:
result.channel_points_custom_reward_id = d["channel_points_custom_reward_id"]
if d.has("source_broadcaster_user_id") and d["source_broadcaster_user_id"] != null:
result.source_broadcaster_user_id = d["source_broadcaster_user_id"]
if d.has("source_broadcaster_user_name") and d["source_broadcaster_user_name"] != null:
result.source_broadcaster_user_name = d["source_broadcaster_user_name"]
if d.has("source_broadcaster_user_login") and d["source_broadcaster_user_login"] != null:
result.source_broadcaster_user_login = d["source_broadcaster_user_login"]
if d.has("source_message_id") and d["source_message_id"] != null:
result.source_message_id = d["source_message_id"]
if d.has("source_badges") and d["source_badges"] != null:
for value in d["source_badges"]:
result.source_badges.append(Badge.from_json(value))
return result
## Key: TwitchBadgeDefinition | Value: SpriteFrames
func get_badges(_media_loader: TwitchMediaLoader, scale: int = 1) -> Dictionary[TwitchBadgeDefinition, SpriteFrames]:
var definitions : Array[TwitchBadgeDefinition] = []
for badge in badges:
var badge_definition : TwitchBadgeDefinition = TwitchBadgeDefinition.new(badge.set_id, badge.id, scale, broadcaster_user_id)
definitions.append(badge_definition)
var emotes : Dictionary[TwitchBadgeDefinition, SpriteFrames] = await _media_loader.get_badges(definitions)
return emotes
## Key: TwitchBadgeDefinition | Value: SpriteFrames
func get_source_badges(_media_loader: TwitchMediaLoader, scale: int = 1) -> Dictionary[TwitchBadgeDefinition, SpriteFrames]:
var definitions : Array[TwitchBadgeDefinition] = []
for badge in source_badges:
var badge_definition : TwitchBadgeDefinition = TwitchBadgeDefinition.new(badge.set_id, badge.id, scale, broadcaster_user_id)
definitions.append(badge_definition)
var emotes : Dictionary[TwitchBadgeDefinition, SpriteFrames] = await _media_loader.get_badges(definitions)
return emotes
## Returns a the color of the user or the default when its not set never null
func get_color(default_color: String = "#AAAAAA") -> String:
return default_color if color == null || color == "" else color
## Preload all emojis in parallel to reduce loadtime
func load_emotes_from_fragment(_media_loader: TwitchMediaLoader) -> Dictionary[TwitchEmoteDefinition, SpriteFrames]:
var emotes_to_load : Array[TwitchEmoteDefinition] = []
for fragment : TwitchChatMessage.Fragment in message.fragments:
match fragment.type:
TwitchChatMessage.FragmentType.emote:
var definition : TwitchEmoteDefinition = TwitchEmoteDefinition.new(fragment.emote.id)
emotes_to_load.append(definition)
return await _media_loader.get_emotes_by_definition(emotes_to_load)

View file

@ -0,0 +1 @@
uid://bxu8no18dq2e3

View file

@ -0,0 +1,173 @@
@icon("res://addons/twitcher/assets/command-icon.svg")
extends Twitcher
# Untested yet
## A single command like !lurk
class_name TwitchCommand
static var ALL_COMMANDS: Array[TwitchCommand] = []
## Called when the command got received in the right format
signal command_received(from_username: String, info: TwitchCommandInfo, args: PackedStringArray)
## Called when the command got received in the wrong format
signal received_invalid_command(from_username: String, info: TwitchCommandInfo, args: PackedStringArray)
## Required permission to execute the command
enum PermissionFlag {
EVERYONE = 0,
VIP = 1,
SUB = 2,
MOD = 4,
STREAMER = 8,
MOD_STREAMER = 12, # Mods and the streamer
NON_REGULAR = 15 # Everyone but regular viewers
}
## Where the command should be accepted
enum WhereFlag {
CHAT = 1,
WHISPER = 2,
ANYWHERE = 3
}
@export var command_prefixes : Array[String] = ["!"]
## Name Command
@export var command: String
## Optional names of commands
@export var aliases: Array[String]
## Description for the user
@export_multiline var description: String
## Minimal amount of argument 0 means no argument needed
@export var args_min: int = 0
## Max amount of arguments -1 means infinite
@export var args_max: int = -1
## Wich role of user is allowed to use it
@export var permission_level: PermissionFlag = PermissionFlag.EVERYONE
## Where is it allowed to use chat or whisper or both
@export var where: WhereFlag = WhereFlag.CHAT
## All allowed users empty array means everyone
@export var allowed_users: Array[String] = []
## All chatrooms where the command listens to
@export var listen_to_chatrooms: Array[String] = []
## The eventsub to listen for chatmessages
@export var eventsub: TwitchEventsub
static func create(
eventsub: TwitchEventsub,
cmd_name: String,
callable: Callable,
min_args: int = 0,
max_args: int = 0,
permission_level: int = PermissionFlag.EVERYONE,
where: int = WhereFlag.CHAT,
allowed_users: Array[String] = [],
listen_to_chatrooms: Array[String] = []) -> TwitchCommand:
var command := TwitchCommand.new()
command.eventsub = eventsub
command.command = cmd_name
command.command_received.connect(callable)
command.args_min = min_args
command.args_max = max_args
command.permission_level = permission_level
command.where = where
command.allowed_users = allowed_users
command.listen_to_chatrooms = listen_to_chatrooms
return command
func _enter_tree() -> void:
if eventsub == null: eventsub = TwitchEventsub.instance
eventsub.event.connect(_on_event)
ALL_COMMANDS.append(self)
func _exit_tree() -> void:
eventsub.event.disconnect(_on_event)
ALL_COMMANDS.erase(self)
func _on_event(type: StringName, data: Dictionary) -> void:
if type == TwitchEventsubDefinition.CHANNEL_CHAT_MESSAGE.value:
if where & WhereFlag.CHAT != WhereFlag.CHAT: return
var message : String = data.message.text
var username : String = data.chatter_user_login
var channel_name : String = data.broadcaster_user_login
if not _should_handle(message, username, channel_name): return
var chat_message = TwitchChatMessage.from_json(data)
_handle_command(username, message, channel_name, chat_message)
if type == TwitchEventsubDefinition.USER_WHISPER_MESSAGE.value:
if where & WhereFlag.WHISPER != WhereFlag.WHISPER: return
var message : String = data.whisper.text
var from_user : String = data.from_user_login
if not _should_handle(message, from_user, from_user): return
_handle_command(from_user, message, data.to_user_login, data)
func add_alias(alias: String) -> void:
aliases.append(alias)
func _should_handle(message: String, username: String, channel_name: String) -> bool:
if not listen_to_chatrooms.is_empty() && not listen_to_chatrooms.has(channel_name): return false
if not allowed_users.is_empty() && not allowed_users.has(username): return false
if not command_prefixes.has(message.left(1)): return false
# remove the command symbol in front
message = message.right(-1)
var split : PackedStringArray = message.split(" ", true, 1)
var current_command := split[0]
if current_command != command && not aliases.has(current_command): return false
return true
func _handle_command(from_username: String, raw_message: String, to_user: String, data: Variant) -> void:
# remove the command symbol in front
raw_message = raw_message.right(-1)
var cmd_msg = raw_message.split(" ", true, 1)
var message = ""
var arg_array : PackedStringArray = []
var command = cmd_msg[0]
var info = TwitchCommandInfo.new(self, to_user, from_username, arg_array, data)
if cmd_msg.size() > 1:
message = cmd_msg[1]
arg_array.append_array(message.split(" ", false))
var to_less_arguments = arg_array.size() < args_min
var to_much_arguments = arg_array.size() > args_max
if to_much_arguments && args_max != -1 || to_less_arguments:
received_invalid_command.emit(from_username, info, arg_array)
return
var premission_required = permission_level != 0
if premission_required:
var user_perm_flags = _get_perm_flag_from_tags(data)
if user_perm_flags & permission_level == 0:
received_invalid_command.emit(from_username, info, arg_array)
return
if arg_array.size() == 0:
if args_min > 0:
received_invalid_command.emit(from_username, info, arg_array)
return
var empty_args: Array[String] = []
if args_max > 0:
command_received.emit(from_username, info, empty_args)
else:
command_received.emit(from_username, info, empty_args)
else:
command_received.emit(from_username, info, arg_array)
func _get_perm_flag_from_tags(data : Variant) -> int:
var flag: int = 0
if data is TwitchChatMessage:
var message: TwitchChatMessage = data as TwitchChatMessage
for badge in message.badges:
match badge.set_id:
"broadcaster": flag += PermissionFlag.STREAMER
"vip": flag += PermissionFlag.VIP
"moderator": flag += PermissionFlag.MOD
"subscriber": flag += PermissionFlag.SUB
return flag

View file

@ -0,0 +1 @@
uid://bmluckfvgm1c2

View file

@ -0,0 +1,83 @@
@icon("res://addons/twitcher/assets/command-icon.svg")
extends TwitchCommand
class_name TwitchCommandHelp
## Used to determine the Sender User if empty and to send the message back
@export var twitch_api: TwitchAPI
## Sender User that will send the answers on the command. Can be empty then the current user will be used
@export var sender_user: TwitchUser
var _current_user: TwitchUser
func _ready() -> void:
if command == "": command = "help"
command_received.connect(_on_command_receive)
if twitch_api == null: twitch_api = TwitchAPI.instance
if twitch_api == null:
push_error("Command is missing TwitchAPI to answer!")
return
var response: TwitchGetUsers.Response = await twitch_api.get_users(TwitchGetUsers.Opt.new())
_current_user = response.data[0]
if sender_user == null: sender_user = _current_user
func _on_command_receive(from_username: String, info: TwitchCommandInfo, args: PackedStringArray) -> void:
if info.original_message is TwitchChatMessage:
var help_message: String = _generate_help_message(args, false)
var chat_message: TwitchChatMessage = info.original_message as TwitchChatMessage
var message_body: TwitchSendChatMessage.Body = TwitchSendChatMessage.Body.new()
message_body.broadcaster_id = chat_message.broadcaster_user_id
message_body.sender_id = sender_user.id
message_body.message = help_message
message_body.reply_parent_message_id = chat_message.message_id
twitch_api.send_chat_message(message_body)
else:
var help_message: String = _generate_help_message(args, true)
var message: Dictionary = info.original_message
if message["to_user_id"] != _current_user.id:
push_error("Can't answer the whisper message receiver is not the user that will be used as sender!")
return
var message_body: TwitchSendWhisper.Body = TwitchSendWhisper.Body.new()
message_body.message = help_message
twitch_api.send_whisper(message_body, message["to_user_id"], message["from_user_id"])
func _generate_help_message(args: Array[String], whisper_only: bool) -> String:
var message: String = ""
var show_details: bool = not args.is_empty()
for command in TwitchCommand.ALL_COMMANDS:
if command == self: continue
var should_be_added: bool = command.where == TwitchCommand.WhereFlag.ANYWHERE \
|| command.where == TwitchCommand.WhereFlag.WHISPER && whisper_only \
|| command.where == TwitchCommand.WhereFlag.CHAT && not whisper_only
if not args.is_empty():
should_be_added = should_be_added && _is_command_in_args(command, args)
if should_be_added:
if show_details:
message += "[%s%s - %s] " % [command.command_prefixes[0], command.command, command.description]
else:
message += "%s%s, " % [command.command_prefixes[0], command.command]
if message == "":
return "No commands registered"
elif not show_details:
message = message.trim_suffix(", ")
message = "List of all Commands: %s | You can use '!help COMMAND' for details!" % message
return message
func _is_command_in_args(command: TwitchCommand, args: Array[String]) -> bool:
for arg in args:
if command.command == arg:
return true
if command.aliases.has(arg):
return true
return false

View file

@ -0,0 +1 @@
uid://ch0rxi1ogjx3q

View file

@ -0,0 +1,25 @@
extends RefCounted
## Meta information about the command sender
class_name TwitchCommandInfo
var command : TwitchCommand
var channel_name : String
var username : String
var arguments : Array[String]
## Depending on the type it's either a TwitchChatMessage or a Dictionary of the whisper message data
var original_message : Variant
func _init(
_command: TwitchCommand,
_channel_name: String,
_username: String,
_arguments: Array[String],
_original_message: Variant):
command = _command
channel_name = _channel_name
username = _username
arguments = _arguments
original_message = _original_message

View file

@ -0,0 +1 @@
uid://c5k8j4ag3n0su

View file

@ -0,0 +1,10 @@
[gd_resource type="Resource" script_class="OAuthToken" load_steps=3 format=3 uid="uid://m7epy882axmp"]
[ext_resource type="Resource" uid="uid://c4scwuk8q0r40" path="res://addons/twitcher/lib/oOuch/default_key_provider.tres" id="1_byqke"]
[ext_resource type="Script" uid="uid://b52xp7c23ucfk" path="res://addons/twitcher/lib/oOuch/oauth_token.gd" id="2_phbii"]
[resource]
script = ExtResource("2_phbii")
_crypto_key_provider = ExtResource("1_byqke")
_identifier = "EditorToken"
_cache_path = "user://auth.conf"

View file

@ -0,0 +1,606 @@
@icon("res://addons/twitcher/assets/api-icon.svg")
@tool
extends Twitcher
class_name TwitchAPIGenerator
const suffixes: Array[String] = ["Response", "Body", "Opt"]
const api_output_path = "res://addons/twitcher/generated/"
const twitch_api_header : String = """@tool
extends Twitcher
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
## Interaction with the Twitch REST API.
class_name TwitchAPI
static var _log: TwitchLogger = TwitchLogger.new("TwitchAPI")
static var instance: TwitchAPI
## Maximal tries to reauthrorize before giving up the request.
const MAX_AUTH_ERRORS = 3
## Called when the API returns unauthenticated mostly cause the accesstoken is expired
signal unauthenticated
## Called when the API returns 403 means there are permissions / scopes missing
signal unauthorized
## To authorize against the Twitch API
@export var token: OAuthToken:
set(val):
token = val
update_configuration_warnings()
## OAuth settings needed for client information
@export var oauth_setting: OAuthSetting:
set(val):
oauth_setting = val
update_configuration_warnings()
## URI to the Twitch API
@export var api_host: String = "https://api.twitch.tv/helix"
## Client to make HTTP requests
var client: BufferedHTTPClient
func _ready() -> void:
client = BufferedHTTPClient.new()
client.name = "ApiClient"
add_child(client)
func _enter_tree() -> void:
if instance == null: instance = self
func _exit_tree() -> void:
if instance == self: instance = null
func _get_configuration_warnings() -> PackedStringArray:
var result: PackedStringArray = []
if token == null:
result.append("Please set a token to use")
if oauth_setting == null:
result.append("Please set the correct oauth settings")
return result
func request(path: String, method: int, body: Variant = "", content_type: String = "", error_count: int = 0) -> BufferedHTTPClient.ResponseData:
var header : Dictionary = {
"Authorization": "Bearer %s" % [await token.get_access_token()],
"Client-ID": oauth_setting.client_id
}
if content_type != "":
header["Content-Type"] = content_type
var request_body: String = ""
if body == null || (body is String && body == ""):
request_body = ""
elif body is Object && body.has_method("to_json"):
request_body = body.to_json()
else:
request_body = JSON.stringify(body)
var req: BufferedHTTPClient.RequestData = client.request(api_host + path, method, header, request_body)
var res: BufferedHTTPClient.ResponseData = await client.wait_for_request(req)
# Try to fix Godot TLS Bug
if res.result == 5:
return await retry(req, res, path, method, body, content_type, error_count + 1)
match res.response_code:
400:
var error_message: String = res.response_data.get_string_from_utf8()
_log.e("'%s' failed cause of: \\n%s" % [path, error_message])
401: # Token expired / or missing permissions
_log.e("'%s' is unauthorized. It is probably your scopes." % path)
unauthorized.emit()
403:
_log.i("'%s' is unauthenticated. Refresh token." % path)
unauthenticated.emit()
await token.authorized
return await retry(req, res, path, method, body, content_type, error_count + 1)
return res
func retry(request: BufferedHTTPClient.RequestData,
response: BufferedHTTPClient.ResponseData,
path: String,
method: int,
body: Variant = "",
content_type: String = "",
error_count: int = 0) -> BufferedHTTPClient.ResponseData:
if error_count + 1 < MAX_AUTH_ERRORS:
return await request(path, method, body, content_type, error_count + 1)
else:
# Give up the request after trying multiple times and
# return an empty response with correct error code
var empty_response: BufferedHTTPClient.ResponseData = client.empty_response(request)
empty_response.response_code = response.response_code
return empty_response
## Converts unix timestamp to RFC 3339 (example: 2021-10-27T00:00:00Z) when passed a string uses as is
static func get_rfc_3339_date_format(time: Variant) -> String:
if typeof(time) == TYPE_INT:
var date_time = Time.get_datetime_dict_from_unix_time(time)
return "%s-%02d-%02dT%02d:%02d:%02dZ" % [date_time['year'], date_time['month'], date_time['day'], date_time['hour'], date_time['minute'], date_time['second']]
return str(time)
"""
@export var parser: TwitchAPIParser
var grouped_files: Dictionary[String, Variant] = {}
func prepare_component(component: TwitchGenComponent) -> void:
if component._is_root:
var base_name = get_base_name(component._classname)
# No suffix class lives by its own
if base_name == component._classname:
if grouped_files.has(base_name):
push_error("That file shouldn't exist: %s" % base_name)
component._classname = "Twitch" + component._classname
grouped_files[base_name] = component
else:
var file: GroupedComponent = grouped_files.get(base_name, GroupedComponent.new())
file.base_name = "Twitch" + base_name
file.components.append(component)
grouped_files[base_name] = file
component._classname = component._classname.trim_prefix(base_name)
component.set_meta("fqdn", file.base_name + "." + component._classname)
var sub_components_to_update: Array[TwitchGenComponent] = component._sub_components.values().duplicate()
for sub_component in sub_components_to_update:
sub_component._classname = component._classname + sub_component._classname
sub_components_to_update.append_array(sub_component._sub_components.values())
pass
func generate_api() -> void:
for component: Variant in parser.components:
prepare_component(component)
# Generate TwitchAPI
var twitch_api_code = twitch_api_header
for method: TwitchGenMethod in parser.methods:
twitch_api_code += method_code(method)
write_output_file(api_output_path + "twitch_api.gd", twitch_api_code)
# Generate Components
for component: Variant in grouped_files.values():
var code = ""
if component is GroupedComponent:
code = group_code(component)
else:
code = component_code(component, 0)
write_output_file(api_output_path + component.get_filename(), code)
print("API regenerated you can find it under: %s" % api_output_path)
class GroupedComponent extends RefCounted:
var base_name: String
var prefix: String
var components: Array[TwitchGenComponent] = []
func _update_base_name(val: String) -> void:
base_name = val
func get_filename() -> String:
return base_name.to_snake_case() + ".gd"
func group_code(group: GroupedComponent) -> String:
var code = """@tool
extends TwitchData
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
class_name {name}
""".format({"name": group.base_name})
for component in group.components:
component._is_root = false
code += "\n\n"
code += component_code(component, 1)
return code
#region Field Code Generation
func field_declaration(field: TwitchGenField) -> String:
var type = get_type(field._type, field._is_array)
return """
## {description}
@export var {name}: {type}:
set(val):
{name} = val
track_data(&"{name}", val)\n""".format({
"name": field._name,
"description": ident(field._description, 0, "## "),
"type": type
})
#endregion
#region Parameter Code Generation
#func get_code() -> String:
#if _name == "broadcaster_id":
#var default_value = "default_broadcaster_login" if _type == "String" else "[default_broadcaster_login]"
#return "%s: %s = %s" % [_name, get_type(), default_value]
#return "%s: %s" % [_name, get_type()]
#endregion
#region Method Code Generation
func parameter_doc(method: TwitchGenMethod) -> String:
if method._required_parameters.is_empty():
return "## [no required query parameters to describe]"
var doc : String = ""
for parameter: TwitchGenParameter in method._required_parameters:
doc += "## {name} - {documentation} \n".format({
'name': parameter._name,
'documentation': ident(parameter._description, 0, "## ")
})
return doc.rstrip("\n")
func parameter_array(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> Array[String]:
var parameters : Array[String] = []
if method._contains_body: parameters.append(get_parameter("body", method._body_type, false, with_type, fully_qualified))
if method._contains_optional: parameters.append(get_parameter("opt", method.get_optional_type(), false, with_type, fully_qualified))
method._parameters.sort_custom(TwitchGenParameter.sort)
for parameter: TwitchGenParameter in method._required_parameters:
parameters.append(get_parameter(parameter._name, parameter._type, parameter._is_array, with_type, fully_qualified))
return parameters
func method_parameter(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> String:
return ", ".join(parameter_array(method, with_type, fully_qualified))
func path_code(method: TwitchGenMethod) -> String:
var body_code : String = "var path = \"%s?\"\n" % method._path
if method._contains_optional:
body_code += "var optionals: Dictionary[StringName, Variant] = {}\n"
body_code += "if opt != null: optionals = opt.to_dict()\n"
for parameter: TwitchGenParameter in method._parameters:
if parameter._required:
body_code += parameter_path_code(parameter) + "\n"
else:
body_code += "if optionals.has(\"%s\"):\n" % parameter._name
body_code += "\t%s\n" % ident(parameter_path_code(parameter, "optionals."), 1)
return body_code
func parameter_path_code(parameter: TwitchGenParameter, prefix: String = "") -> String:
var body: String
if parameter._is_time:
body = "path += \"{key}=\" + get_rfc_3339_date_format({value}) + \"&\""
elif parameter._is_array:
body = """
for param in {value}:
path += "{key}=" + str(param) + "&" """.trim_prefix("\n\t")
else:
body = "path += \"{key}=\" + str({value}) + \"&\""
return body.format({
'value': prefix + parameter._name,
'key': parameter._name
})
## Exceptional method cause twitch api is not uniform
func paging_code_stream_schedule() -> String:
return """
if parsed_result.data.pagination != null:
opt.after = parsed_result.data.pagination.cursor
parsed_result.data._next_page = get_channel_stream_schedule.bind(opt, broadcaster_id)\n"""
func paging_code(method: TwitchGenMethod) -> String:
if method._name == "get_channel_stream_schedule":
return paging_code_stream_schedule()
var code: String = ""
code += "if parsed_result.pagination != null:\n"
var after_parameter: TwitchGenParameter = method.get_parameter_by_name("after")
var result_component: TwitchGenComponent = get_component(method._result_type)
var pagination_parameter: TwitchGenField = result_component.get_field_by_name("pagination")
if pagination_parameter == null:
print("Check %s paging without paging?" % method._name)
pass
elif pagination_parameter._type == "String":
code += "\tvar cursor: String = parsed_result.pagination\n"
else:
code += "\tvar cursor: String = parsed_result.pagination.cursor\n"
if after_parameter._required:
code += "\t{parameter} = cursor\n"
else:
code += "\topt.{parameter} = cursor\n"
code += "\tparsed_result._next_page = {name}.bind({parameters})\n"
return code.format({
"parameter": after_parameter._name,
"name": method._name,
"parameters": method_parameter(method)
})
func response_code(method: TwitchGenMethod) -> String:
var code: String = ""
var result_type = get_type(method._result_type, false, true)
if result_type != "BufferedHTTPClient.ResponseData":
code = """
var result: Variant = JSON.parse_string(response.response_data.get_string_from_utf8())
var parsed_result: {result_type} = {result_type}.from_json(result)
parsed_result.response = response
""".format({ 'result_type': result_type })
if method._has_paging: code += paging_code(method)
code += "return parsed_result"
else:
code = "return response"
return code
func method_code(method: TwitchGenMethod) -> String:
return """
## {summary}
##
{parameter_doc}
##
## {doc_url}
func {name}({parameters}) -> {result_type}:
{path_code}
var response: BufferedHTTPClient.ResponseData = await request(path, HTTPClient.METHOD_{method}, {body_variable}, "{content_type}")
{response_code}
""".format({
"summary": method._summary,
"parameter_doc": parameter_doc(method),
"doc_url": method._doc_url,
"name": method._name,
"parameters": method_parameter(method, true, true),
"result_type": get_type(method._result_type, false, true),
"path_code": ident(path_code(method), 1),
"content_type": get_type(method._content_type, false, true),
"method": method._http_verb.to_upper(),
"body_variable": "body" if method._contains_body else "\"\"",
"response_code": ident(response_code(method), 1),
})
#endregion
#region Component Code Generation
func component_code(component: TwitchGenComponent, level: int = 0) -> String:
var code: String = ""
if component._is_root:
code += """@tool
extends TwitchData
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
## {description}
## {ref}
class_name {classname}
"""
else:
code = """
## {description}
## {ref}
class {classname} extends TwitchData:
"""
var class_code : String = ""
for field: TwitchGenField in component._fields:
class_code += field_declaration(field)
if component._is_response:
class_code += "var response: BufferedHTTPClient.ResponseData"
class_code += "\n\n"
class_code += create_code(component) + "\n\n"
class_code += from_json_code(component)
if component._has_paging:
class_code += "\n\n" + iter_code(component)
var sub_component_code: String
for sub_component in component._sub_components.values():
sub_component_code += "\n\n" + component_code(sub_component, 1)
return code.format({
"description": ident(component._description, 0, "## "),
"classname": component._classname,
"ref": component._ref
}) + ident(class_code, level) + sub_component_code
func create_code(component: TwitchGenComponent) -> String:
var parameters: Array[String] = []
for field in component._fields:
if field._is_required:
parameters.append("_" + get_parameter(field._name, field._type, field._is_array))
var variable_name = component._classname.to_snake_case()
var code : String = """
## Constructor with all required fields.
static func create({parameters}) -> {classname}:
var {variablename}: {classname} = {classname}.new()\n""".format({
"parameters": ", ".join(parameters),
"classname": component._classname,
"variablename": variable_name
})
for field in component._fields:
if field._is_required:
code += "\t{classname}.{name} = _{name}\n".format({
"name": field._name,
"classname": variable_name
})
code += "\treturn %s" % variable_name
return code
func from_json_code(component: TwitchGenComponent) -> String:
var code : String = """
static func from_json(d: Dictionary) -> {classname}:
var result: {classname} = {classname}.new()
""".format({"classname": component._classname})
for field: TwitchGenField in component._fields:
code += "\tif d.get(\"{name}\", null) != null:\n"
if field._is_typed_array:
code += """
for value in d["{name}"]:
result.{name}.append({type}.from_json(value))\n""".lstrip("\n")
elif field._is_array:
code += """
for value in d["{name}"]:
result.{name}.append(value)\n""".lstrip("\n")
elif field._is_sub_class:
code += "\t\tresult.{name} = {type}.from_json(d[\"{name}\"])\n"
else:
code += "\t\tresult.{name} = d[\"{name}\"]\n"
code = code.format({
"name": field._name,
"type": get_type(field._type, false)
})
code += "\treturn result\n"
return code
func iter_code(component: TwitchGenComponent) -> String:
var data_variable_name: String = "data"
var path_to_data: String = ""
if component._ref == "#/components/schemas/GetChannelStreamScheduleResponse/Data":
data_variable_name = "segments"
path_to_data = "data."
var code: String
if component._ref == "#/components/schemas/GetExtensionLiveChannelsResponse":
code += """
func _has_pagination() -> bool:
if pagination == null || pagination == "": return false
return true
"""
else:
code += """
func _has_pagination() -> bool:
if pagination == null: return false
if pagination.cursor == null || pagination.cursor == "": return false
return true
"""
code += """
var _next_page: Callable
var _cur_iter: int = 0
func next_page() -> {response_type}:
var response: {response_type} = await _next_page.call()
_cur_iter = 0
_next_page = response.{path_to_data}_next_page
{copy_code}
return response
func _iter_init(iter: Array) -> bool:
if {data_variable_name}.is_empty(): return false
iter[0] = {data_variable_name}[0]
return {data_variable_name}.size() > 0
func _iter_next(iter: Array) -> bool:
if {data_variable_name}.size() - 1 > _cur_iter:
_cur_iter += 1
iter[0] = {data_variable_name}[_cur_iter]
if {data_variable_name}.size() - 1 == _cur_iter && not _has_pagination():
return false
return true
func _iter_get(iter: Variant) -> Variant:
if {data_variable_name}.size() - 1 == _cur_iter && _has_pagination():
await next_page()
return iter"""
var copy_code: String
for field in component._fields:
copy_code += "\t{_name} = response.{path_to_data}{_name}\n".format(field)
return code.format({
"data_variable_name": data_variable_name,
"copy_code": copy_code,
"path_to_data": path_to_data,
"response_type": component.get_root_classname()
})
#endregion
#region Utils
func get_type(type: String, is_array: bool = false, full_qualified: bool = false) -> String:
var result_type : String = ""
if type.begins_with("#"):
var component: TwitchGenComponent = parser.get_component_by_ref(type)
result_type = component._classname
if full_qualified and component.has_meta("fqdn"):
result_type = component.get_meta("fqdn")
else:
result_type = type
return result_type if not is_array else "Array[%s]" % result_type
func get_component(type: String) -> TwitchGenComponent:
if type.begins_with("#"):
return parser.get_component_by_ref(type)
else:
return null
func ident(code: String, level: int, padding: String = "") -> String:
return code.replace("\n", "\n" + "\t".repeat(level) + padding)
# Writes the processed content to the output file.
func write_output_file(file_output: String, content: String) -> void:
var file = FileAccess.open(file_output, FileAccess.WRITE);
if file == null:
var error_message = error_string(FileAccess.get_open_error());
push_error("Failed to open output file: %s\n%s" % [file_output, error_message])
return
file.store_string(content)
file.flush()
file.close()
func get_base_name(file: String) -> String:
var new_file: String = file
for suffix: String in suffixes:
new_file = new_file.trim_suffix(suffix)
return new_file
func get_parameter(title: String, type: String, is_array = false, with_type: bool = true, fully_qualified: bool = false) -> String:
if with_type:
return "{name}: {type}".format({
"name": title,
"type": get_type(type, is_array, fully_qualified)
})
else:
return title
#endregion

View file

@ -0,0 +1 @@
uid://cetl2un34bjb1

View file

@ -0,0 +1,247 @@
@icon("res://addons/twitcher/assets/api-icon.svg")
@tool
extends Twitcher
class_name TwitchAPIParser
const SWAGGER_API = "https://raw.githubusercontent.com/DmitryScaletta/twitch-api-swagger/refs/heads/main/openapi.json"
var definition: Dictionary = {}
var component_map: Dictionary[String, TwitchGenComponent] = {}
var components: Array[TwitchGenComponent] = []
var methods: Array[TwitchGenMethod] = []
var client: BufferedHTTPClient = BufferedHTTPClient.new()
signal component_added(component: TwitchGenComponent)
signal method_added(method: TwitchGenMethod)
class ComponentRepo extends RefCounted:
var _component: TwitchGenComponent
var _component_map: Dictionary[String, TwitchGenComponent]
func get_comp(component_name: String) -> TwitchGenComponent:
var component = _component.get_component(component_name)
if component != null: return component
return _component_map.get(component_name)
func _init(component: TwitchGenComponent, component_map: Dictionary[String, TwitchGenComponent]) -> void:
_component = component
_component_map = component_map
func _ready() -> void:
client.name = "APIGeneratorClient"
func parse_api() -> void:
print("start generating API")
if definition == {}:
print("load Twitch definition")
definition = await _load_swagger_definition()
_parsing_components()
_parsing_paths()
func _load_swagger_definition() -> Dictionary:
add_child(client)
client.max_error_count = 3
var request = client.request(SWAGGER_API, HTTPClient.METHOD_GET, {}, "")
var response_data = await client.wait_for_request(request)
if response_data.error:
printerr("Cant generate API")
return {}
var response_str = response_data.response_data.get_string_from_utf8()
var response = JSON.parse_string(response_str)
remove_child(client)
return response
func _parsing_components() -> void:
var schemas = definition["components"]["schemas"]
for schema_name in schemas:
var schema: Dictionary = schemas[schema_name]
if schema["type"] != "object":
printerr("Not an object")
continue
var ref = "#/components/schemas/" + schema_name
var component = TwitchGenComponent.new(schema_name, ref)
component._is_root = true
component._is_response = true
_parse_properties(component, schema)
_add_component(ref, component)
func _parse_properties(component: TwitchGenComponent, schema: Dictionary) -> void:
var properties = schema["properties"]
for property_name: String in properties:
var property: Dictionary = properties[property_name]
var field: TwitchGenField = TwitchGenField.new()
field._name = property_name
field._description = property.get("description", "")
field._type = _get_param_type(property)
var classname: String = property_name.capitalize().replace(" ", "")
if property.has("properties"):
var sub_component = _add_sub_component(classname, field._description, component, property)
field._type = sub_component._ref
## Arrays that has custom types
elif property.get("type", "") == "array":
field._is_array = true
field._is_sub_class = false
var items = property.get("items", {})
if items.has("$ref"):
field._type = items.get("$ref")
elif items.has("properties"):
var sub_component = _add_sub_component(classname, field._description, component, items)
field._type = sub_component._ref
component.add_field(field)
var requires: Array = schema.get("required", [])
for required_field: String in requires:
var field: TwitchGenField = component.get_field_by_name(required_field)
field._is_required = true
func _add_sub_component(classname: String, description: String, parent_component: TwitchGenComponent, properties: Dictionary) -> TwitchGenComponent:
var ref: String = parent_component._ref + "/" + classname
var sub_component = TwitchGenComponent.new(classname, ref)
sub_component._description = description
_parse_properties(sub_component, properties)
parent_component.add_component(sub_component)
_add_component(ref, sub_component)
return sub_component
func _parsing_paths() -> void:
var paths = definition.get("paths", {})
for path in paths:
var method_specs = paths[path]
for http_verb: String in method_specs:
var method_spec = method_specs[http_verb] as Dictionary
var method = _parse_method(http_verb, method_spec)
method._path = path
if method._contains_optional:
var component : TwitchGenComponent = method.get_optional_component()
_add_component(component._ref, component)
methods.append(method)
method_added.emit(method)
func _parse_method(http_verb: String, method_spec: Dictionary) -> TwitchGenMethod:
var method: TwitchGenMethod = TwitchGenMethod.new()
method._http_verb = http_verb
method._name = method_spec.get("operationId", "method_" + http_verb).replace("-", "_")
method._summary = method_spec.get("summary", "No summary provided.")
method._description = method_spec.get("description", "No description provided.")
method._doc_url = method_spec.get("externalDocs", {}).get("url", "No link provided")
_parse_parameters(method, method_spec)
# Body Type
if method_spec.has("requestBody"):
method._body_type = "Dictionary"
var ref = method_spec.get("requestBody").get("content", {}).get("application/json", {}).get("schema", {}).get("$ref", "")
if ref != "":
method._body_type = ref
# Result Type
method._result_type = "BufferedHTTPClient.ResponseData"
var responses = method_spec.get("responses", {})
if responses.has("200") || responses.has("202"):
var content: Dictionary = {}
if responses.has("200"):
content = responses["200"].get("content", {})
elif content == {}:
content = responses["202"].get("content", {})
# Assuming the successful response is a JSON object
method._result_type = "Dictionary"
# Special case for /schedule/icalendar
if content.has("text/calendar"):
method._result_type = "BufferedHTTPClient.ResponseData"
# Try to resolve the component references
var ref = content.get("application/json", {}).get("schema", {}).get("$ref", "")
if ref != "":
method._result_type = ref
# Content Type
if method_spec.has("requestBody"):
var requestBody = method_spec.get("requestBody")
var content = requestBody.get("content")
method._content_type = content.keys()[0]
elif http_verb == "POST":
method._content_type = "application/x-www-form-urlencoded"
return method
func _parse_parameters(method: TwitchGenMethod, method_spec: Dictionary) -> void:
var parameter_specs = method_spec.get("parameters", [])
for parameter_spec in parameter_specs:
var parameter: TwitchGenParameter = TwitchGenParameter.new()
var schema = parameter_spec["schema"]
parameter._name = parameter_spec.get("name", "")
parameter._description = parameter_spec.get("description", "")
parameter._type = _get_param_type(schema)
parameter._required = parameter_spec.get("required", false)
parameter._is_time = schema.get("format", "") == "date-time"
parameter._is_array = schema.get("type", "") == "array"
method.add_parameter(parameter)
func _add_component(ref: String, component: TwitchGenComponent) -> void:
components.append(component)
component_map[ref] = component
func get_component_by_ref(ref: String) -> TwitchGenComponent:
return component_map[ref]
func _get_param_type(schema: Dictionary) -> String:
if schema.has("$ref"):
return schema["$ref"]
if not schema.has("type"):
return "Variant" # Maybe ugly
var type = schema["type"]
var format = schema.get("format", "")
match type:
"object":
if schema.has("additinalProperties"):
return _get_param_type(schema["additinalProperties"])
return "Dictionary"
"string":
# Why did I do this in the first place?
# Lets disable and see if problems appear
#if format == "date-time":
# return "Variant"
return "String"
"integer":
return "int"
"number":
return "float" if format == "float" else "int"
"boolean":
return "bool"
"array":
var ref: String = schema["items"].get("$ref", "")
if schema["items"].get("type", "") == "string":
return "String"
elif ref != "":
return ref
else:
return "Variant"
_: return "Variant"

View file

@ -0,0 +1 @@
uid://o7q04krfh33l

View file

@ -0,0 +1,3 @@
extends RefCounted
class_name TwitchGen

View file

@ -0,0 +1 @@
uid://cvtstbwwwf7gf

View file

@ -0,0 +1,60 @@
@tool
extends TwitchGen
class_name TwitchGenComponent
var _classname: String
var _ref: String
var _description: String
var _fields: Array[TwitchGenField] = []
var _field_map: Dictionary[String, TwitchGenField] = {}
var _parent_component: TwitchGenComponent
var _sub_components: Dictionary[String, TwitchGenComponent] = {}
var _is_root: bool
var _is_response: bool
var _has_paging: bool
var _filename: String:
get(): return _classname.to_snake_case() + ".gd"
func _init(classname: String, ref: String) -> void:
_classname = _sanatize_classname(classname)
_ref = ref
func add_field(field: TwitchGenField) -> void:
_fields.append(field)
_field_map[field._name] = field
if field._name == "pagination":
_has_paging = true
func get_field_by_name(field_name: String) -> TwitchGenField:
return _field_map.get(field_name, null)
func get_root_classname() -> String:
var parent: TwitchGenComponent = self
while parent._parent_component != null:
parent = parent._parent_component
return parent._classname
func get_filename() -> String:
return get_root_classname().to_snake_case() + ".gd"
func _sanatize_classname(val: String) -> String:
match val:
"Image": return "TwitchImage"
"Panel": return "TwitchPanel"
_: return val
func get_component(component_name: String) -> TwitchGenComponent:
return _sub_components.get(component_name)
func add_component(sub_component: TwitchGenComponent) -> void:
_sub_components[sub_component._classname] = sub_component
sub_component._parent_component = self

View file

@ -0,0 +1 @@
uid://cttaojg743q5y

View file

@ -0,0 +1,31 @@
@tool
extends TwitchGen
class_name TwitchGenField
var _name: String:
set = _update_name
var _description: String
var _type: String
var _is_required: bool
var _is_sub_class: bool:
get(): return _type.begins_with("#")
var _is_array: bool
var _is_typed_array: bool:
get(): return _is_array && _type.begins_with("#")
## Couple of names from the Twitch API are messed up like keywords for godot or numbers
func _update_name(val: String) -> void:
match val:
"animated": _name = "animated_format"
"static": _name = "static_format"
"1": _name = "_1"
"2": _name = "_2"
"3": _name = "_3"
"4": _name = "_4"
"1.5": _name = "_1_5"
"100x100": _name = "_100x100"
"24x24": _name = "_24x24"
"300x200": _name = "_300x200"
_: _name = val

View file

@ -0,0 +1 @@
uid://dgx1w74garjdh

View file

@ -0,0 +1,59 @@
@tool
extends TwitchGen
class_name TwitchGenMethod
var _http_verb: String
var _name: String
var _summary: String
var _description: String
var _path: String
var _doc_url: String
var _parameters: Array[TwitchGenParameter] = []
var _parameter_map: Dictionary[String, TwitchGenParameter] = {}
var _required_parameters: Array[TwitchGenParameter]:
get(): return _parameters.filter(func(p): return p._required)
var _optional_parameters: Array[TwitchGenParameter]:
get(): return _parameters.filter(func(p): return not p._required)
var _body_type: String
var _result_type: String
var _content_type: String
var _has_paging: bool
var _contains_optional: bool
var _contains_body: bool:
get(): return _body_type != null and _body_type != ""
func add_parameter(parameter: TwitchGenParameter) -> void:
_parameters.append(parameter)
_contains_optional = _contains_optional || not parameter._required
_parameter_map[parameter._name] = parameter
if parameter._name == "after":
_has_paging = true
func get_parameter_by_name(name: String) -> TwitchGenParameter:
return _parameter_map.get(name)
func get_optional_classname() -> String:
return _name.capitalize().replace(" ", "") + "Opt"
func get_optional_type() -> String:
return "#/components/schemas/" + get_optional_classname()
func get_optional_component() -> TwitchGenComponent:
var component = TwitchGenComponent.new(get_optional_classname(), get_optional_type())
component._description = "All optional parameters for TwitchAPI.%s" % _name
component._is_root = true
for parameter: TwitchGenParameter in _optional_parameters:
var field = TwitchGenField.new()
field._name = parameter._name
field._type = parameter._type
field._description = parameter._description
field._is_required = false
field._is_array = parameter._is_array
component.add_field(field)
return component

View file

@ -0,0 +1 @@
uid://dkwhf6s838d20

View file

@ -0,0 +1,22 @@
@tool
extends TwitchGen
class_name TwitchGenParameter
var _name: String
var _description: String
var _required: bool
var _type: String
var _is_time: bool
var _is_array: bool
static func sort(p1: TwitchGenParameter, p2: TwitchGenParameter) -> bool:
if p1._name == "broadcaster_id":
return false
if p2._name == "broadcaster_id":
return true
if p1._required && not p2._required:
return true
if not p1._required && p2._required:
return false
return p1._name < p2._name

View file

@ -0,0 +1 @@
uid://1ptmbsygcfyt

View file

@ -0,0 +1,37 @@
extends EditorInspectorPlugin
const EventsubConfigProperty = preload("res://addons/twitcher/editor/inspector/twitch_eventsub_config_property.gd")
func _can_handle(object: Object) -> bool:
return object is TwitchEventsubConfig
func _parse_property(object: Object, type: Variant.Type, name: String, \
hint_type: PropertyHint, hint_string: String, usage_flags: int, \
wide: bool) -> bool:
if name == &"condition":
add_property_editor("condition", EventsubConfigProperty.new(), true)
return true
if name == &"type":
add_property_editor("type", ToDocs.new(), true, "Documentation")
return false
class ToDocs extends EditorProperty:
const EXT_LINK = preload("res://addons/twitcher/assets/ext-link.svg")
var docs = Button.new()
func _init() -> void:
docs.text = "To dev.twitch.tv"
docs.icon = EXT_LINK
docs.pressed.connect(_on_to_docs)
add_child(docs)
add_focusable(docs)
func _on_to_docs() -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object()
OS.shell_open(eventsub_config.definition.documentation_link)

View file

@ -0,0 +1 @@
uid://c1afm3xjonwxr

View file

@ -0,0 +1,75 @@
extends EditorProperty
const USER_CONVERTER = preload("res://addons/twitcher/editor/inspector/user_converter.tscn")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
var _container: Node = GridContainer.new()
func _init():
_container.columns = 2
add_child(_container)
set_bottom_editor(_container)
func _on_type_change(new_type: TwitchEventsubDefinition.Type) -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if eventsub_config != null:
for meta in eventsub_config.get_meta_list():
if meta.ends_with("_user"):
eventsub_config.remove_meta(meta)
_create_conditions()
func _update_property() -> void:
_create_conditions()
func _create_conditions() -> void:
for node in _container.get_children():
node.queue_free()
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if eventsub_config == null || eventsub_config.get_class() == &"EditorDebuggerRemoteObject": return
for condition_name: StringName in eventsub_config.definition.conditions:
var condition_value = eventsub_config.condition.get_or_add(condition_name, "")
var condition_title = Label.new()
condition_title.text = condition_name.capitalize()
_container.add_child(condition_title)
var editor_token = TwitchEditorSettings.editor_oauth_token
if condition_name.to_lower().ends_with("user_id") && editor_token.is_token_valid():
var user_converter = USER_CONVERTER.instantiate()
user_converter.changed.connect(_on_changed_user.bind(condition_name))
_container.add_child(user_converter)
if eventsub_config.has_meta(condition_name + "_user"):
var user = eventsub_config.get_meta(condition_name + "_user")
user_converter.update_user(user)
elif condition_value != "":
user_converter.user_id = condition_value
user_converter.reload()
else:
var input = LineEdit.new()
input.text_submitted.connect(_on_change_text.bind(condition_name, input))
input.focus_exited.connect(_on_change_text.bind("", condition_name, input))
input.text = condition_value
input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_container.add_child(input)
func _on_changed_user(user: TwitchUser, condition_name: StringName) -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if user == null:
eventsub_config.condition[condition_name] = ""
eventsub_config.remove_meta(condition_name + "_user")
emit_changed(&"condition", eventsub_config.condition)
else:
eventsub_config.condition[condition_name] = user.id
eventsub_config.set_meta(condition_name + "_user", user)
emit_changed(&"condition", eventsub_config.condition)
func _on_change_text(new_text: String, condition_name: StringName, input: LineEdit) -> void:
print("BLUB")
var eventsub_config: TwitchEventsubConfig = get_edited_object();
eventsub_config.condition[condition_name] = input.text
#emit_changed(&"condition", eventsub_config.condition)

View file

@ -0,0 +1 @@
uid://drv4gmn8akxf2

View file

@ -0,0 +1,83 @@
extends EditorInspectorPlugin
func _can_handle(object: Object) -> bool:
return object is TwitchEventsub || object is TwitchService
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if name == &"scopes" && object.get_class() != &"EditorDebuggerRemoteObject":
if (object is TwitchService && object.eventsub != null) || object is TwitchEventsub:
add_property_editor(&"scope_validation", ScopeValidation.new(), true, "Scope Validation")
return false
class ScopeValidation extends EditorProperty:
const WARNING_LABEL_SETTINGS = preload("res://addons/twitcher/assets/warning_label_settings.tres")
const INFO_LABEL_SETTINGS = preload("res://addons/twitcher/assets/info_label_settings.tres")
var _warning_label: Label = Label.new();
var _apply_scopes: Button = Button.new();
var _needed_scopes: Dictionary = {}
var container: Control = VBoxContainer.new()
func _init():
_warning_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_warning_label.text = "Press validate to check if scopes maybe are missing."
var validate_button = Button.new();
validate_button.text = "Validate";
validate_button.tooltip_text = "Checks the scopes of the subscriptions " \
+ " if they match the defined scopes in the scope " \
+ " property";
validate_button.pressed.connect(_on_validate_scopes);
_apply_scopes.text = "Apply Scopes";
_apply_scopes.tooltip_text = "Apply Scopes to the scope resource in " \
+ " this TwitchEventsub. It maybe not needed depending on the " \
+ " Subscription. Please check the documentation if there is a logical " \
+ " condition and apply the scopes accordingly.";
_apply_scopes.pressed.connect(_on_apply_scopes);
add_child(validate_button)
container.add_child(_warning_label)
add_child(container)
set_bottom_editor(container)
func _on_apply_scopes() -> void:
var scopes = get_edited_object().scopes;
var scopes_to_add: Array[StringName] = [];
for scope in _needed_scopes.values():
scopes_to_add.append(scope);
scopes.add_scopes(scopes_to_add);
_clear_warning();
func _on_validate_scopes() -> void:
var scopes = get_edited_object().scopes;
var subscriptions = get_edited_object().get_subscriptions();
_needed_scopes.clear()
for subscription: TwitchEventsubConfig in subscriptions:
if subscription == null: continue
for scope in subscription.definition.scopes:
_needed_scopes[scope] = scope
for scope in scopes.used_scopes:
_needed_scopes.erase(scope)
if !_needed_scopes.is_empty():
if _apply_scopes.get_parent() == null: container.add_child(_apply_scopes)
_warning_label.label_settings = WARNING_LABEL_SETTINGS
var needed_scopes = ", ".join(_needed_scopes.values())
_warning_label.text = "You may miss scopes please check documentation if you need to add: %s" % needed_scopes;
else:
_clear_warning()
func _clear_warning() -> void:
_warning_label.text = "Scopes seems to be OK for this EventSub."
_warning_label.label_settings = INFO_LABEL_SETTINGS
if _apply_scopes.get_parent() != null:
container.remove_child(_apply_scopes)

View file

@ -0,0 +1 @@
uid://cuo2g5oib1cf3

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